Repository: kubernetes-sigs/external-dns Branch: master Commit: 1c9913bbdbdd Files: 537 Total size: 4.9 MB Directory structure: gitextract_h6x703iq/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── ---bug-report.md │ │ ├── --enhancement-request.md │ │ ├── -support-request.md │ │ └── create-release.md │ ├── dependabot.yml │ ├── labeler.yml │ ├── pull_request_template.md │ ├── renovate-config.js │ ├── renovate.json │ └── workflows/ │ ├── OWNERS │ ├── ci.yaml │ ├── codeql-analysis.yaml │ ├── dependency-update.yaml │ ├── docs.yaml │ ├── end-to-end-tests.yml │ ├── gh-workflow-approve.yaml │ ├── json-yaml-validate.yml │ ├── lint-test-chart.yaml │ ├── lint.yaml │ ├── release-chart.yaml │ ├── staging-image-tester.yaml │ └── validate-crd.yml ├── .gitignore ├── .golangci.yml ├── .ko.yaml ├── .markdownlint.json ├── .pre-commit-config.yaml ├── .spectral.yaml ├── .zappr.yaml ├── CONTRIBUTING.md ├── LICENSE.md ├── Makefile ├── OWNERS ├── README.md ├── SECURITY_CONTACTS ├── api/ │ └── webhook.yaml ├── apis/ │ ├── OWNERS │ ├── api.go │ └── v1alpha1/ │ ├── api.go │ ├── dnsendpoint.go │ ├── groupversion_info.go │ └── zz_generated.deepcopy.go ├── charts/ │ └── OWNERS ├── cloudbuild.yaml ├── code-of-conduct.md ├── config/ │ └── crd/ │ └── standard/ │ └── dnsendpoints.externaldns.k8s.io.yaml ├── controller/ │ ├── OWNERS │ ├── controller.go │ ├── controller_test.go │ ├── events.go │ ├── events_test.go │ ├── execute.go │ ├── execute_test.go │ ├── metrics.go │ └── metrics_test.go ├── docs/ │ ├── 20190708-external-dns-incubator.md │ ├── OWNERS │ ├── advanced/ │ │ ├── configuration-precedence.md │ │ ├── domain-filter.md │ │ ├── events.md │ │ ├── fqdn-templating.md │ │ ├── import-records.md │ │ ├── nat64.md │ │ ├── operational-best-practices.md │ │ ├── rate-limits.md │ │ ├── split-horizon.md │ │ └── ttl.md │ ├── annotations/ │ │ └── annotations.md │ ├── contributing/ │ │ ├── bug-report.md │ │ ├── chart.md │ │ ├── design.md │ │ ├── dev-guide.md │ │ ├── index.md │ │ ├── source-wrappers.md │ │ └── sources-and-providers.md │ ├── deprecation.md │ ├── faq.md │ ├── flags.md │ ├── initial-design.md │ ├── monitoring/ │ │ ├── index.md │ │ └── metrics.md │ ├── overrides/ │ │ └── partials/ │ │ └── copyright.html │ ├── proposal/ │ │ ├── 001-leader-election.md │ │ ├── 002-internal-ipv6-handling-rollback.md │ │ ├── 003-dnsendpoint-graduation-to-beta.md │ │ ├── 004-gateway-api-annotation-placement.md │ │ ├── design-template.md │ │ └── multi-target.md │ ├── providers.md │ ├── registry/ │ │ ├── dynamodb.md │ │ ├── registry.md │ │ └── txt.md │ ├── release.md │ ├── scripts/ │ │ ├── index.html.gotmpl │ │ └── requirements.txt │ ├── snippets/ │ │ ├── contributing/ │ │ │ ├── collect-extdns-info.sh │ │ │ └── collect-resources.sh │ │ ├── exoscale/ │ │ │ ├── extdns.yaml │ │ │ ├── how-to-test.yaml │ │ │ └── rbac.yaml │ │ ├── security-context/ │ │ │ └── extdns-limited-privilege.yaml │ │ ├── traefik-proxy/ │ │ │ ├── ingress-route-default.yaml │ │ │ ├── ingress-route-public-private.yaml │ │ │ ├── traefik-public-private-config.yaml │ │ │ ├── with-cluster-rbac.yaml │ │ │ └── without-rbac.yaml │ │ └── tutorials/ │ │ └── coredns/ │ │ ├── coredns-groups.yaml │ │ ├── etcd.yaml │ │ ├── fixtures.yaml │ │ ├── kind.yaml │ │ ├── values-coredns.yaml │ │ └── values-extdns-coredns.yaml │ ├── sources/ │ │ ├── about.md │ │ ├── crd/ │ │ │ ├── dnsendpoint-aws-example.yaml │ │ │ └── dnsendpoint-example.yaml │ │ ├── crd.md │ │ ├── f5-transportserver.md │ │ ├── f5-virtualserver.md │ │ ├── gateway-api.md │ │ ├── gateway.md │ │ ├── gloo-proxy.md │ │ ├── index.md │ │ ├── ingress.md │ │ ├── istio.md │ │ ├── kong.md │ │ ├── mx-record.md │ │ ├── nodes.md │ │ ├── ns-record.md │ │ ├── openshift.md │ │ ├── pod.md │ │ ├── service.md │ │ ├── traefik-proxy.md │ │ ├── txt-record.md │ │ └── unstructured.md │ ├── tutorials/ │ │ ├── akamai-edgedns.md │ │ ├── alibabacloud.md │ │ ├── anexia-engine.md │ │ ├── aws-filters.md │ │ ├── aws-load-balancer-controller.md │ │ ├── aws-public-private-route53.md │ │ ├── aws-sd.md │ │ ├── aws.md │ │ ├── azure-private-dns.md │ │ ├── azure.md │ │ ├── civo.md │ │ ├── cloudflare.md │ │ ├── contour.md │ │ ├── coredns-etcd.md │ │ ├── coredns.md │ │ ├── crd.md │ │ ├── dnsimple.md │ │ ├── exoscale.md │ │ ├── externalname.md │ │ ├── gandi.md │ │ ├── gke-nginx.md │ │ ├── gke.md │ │ ├── godaddy.md │ │ ├── hostport.md │ │ ├── ionoscloud.md │ │ ├── kops-dns-controller.md │ │ ├── kube-ingress-aws.md │ │ ├── linode.md │ │ ├── myra.md │ │ ├── ns1.md │ │ ├── oracle.md │ │ ├── ovh.md │ │ ├── pdns.md │ │ ├── pihole.md │ │ ├── plural.md │ │ ├── rfc2136.md │ │ ├── scaleway.md │ │ ├── security-context.md │ │ ├── transip.md │ │ └── webhook-provider.md │ └── version-update-playbook.md ├── e2e/ │ ├── deployment.yaml │ ├── provider/ │ │ ├── coredns.yaml │ │ └── etcd.yaml │ └── service.yaml ├── endpoint/ │ ├── OWNERS │ ├── crypto.go │ ├── crypto_test.go │ ├── domain_filter.go │ ├── domain_filter_test.go │ ├── endpoint.go │ ├── endpoint_benchmark_test.go │ ├── endpoint_test.go │ ├── labels.go │ ├── labels_test.go │ ├── target_filter.go │ ├── target_filter_test.go │ ├── utils.go │ ├── utils_test.go │ └── zz_generated.deepcopy.go ├── external-dns.code-workspace ├── go.mod ├── go.sum ├── go.tool.mod ├── go.tool.sum ├── internal/ │ ├── OWNERS │ ├── config/ │ │ └── config.go │ ├── flags/ │ │ ├── binders.go │ │ └── binders_test.go │ ├── gen/ │ │ └── docs/ │ │ ├── flags/ │ │ │ ├── main.go │ │ │ ├── main_test.go │ │ │ └── templates/ │ │ │ └── flags.gotpl │ │ ├── metrics/ │ │ │ ├── main.go │ │ │ ├── main_test.go │ │ │ └── templates/ │ │ │ └── metrics.gotpl │ │ ├── render/ │ │ │ ├── render.go │ │ │ └── render_test.go │ │ └── sources/ │ │ ├── main.go │ │ ├── main_test.go │ │ └── templates/ │ │ └── sources.gotpl │ ├── idna/ │ │ ├── idna.go │ │ └── idna_test.go │ ├── testresources/ │ │ ├── ca.pem │ │ ├── client-cert-key.pem │ │ └── client-cert.pem │ └── testutils/ │ ├── endpoint.go │ ├── endpoint_test.go │ ├── env.go │ ├── helpers.go │ ├── helpers_test.go │ ├── init.go │ ├── log/ │ │ └── log.go │ ├── metrics.go │ └── mock_source.go ├── kustomize/ │ ├── OWNERS │ ├── external-dns-clusterrole.yaml │ ├── external-dns-clusterrolebinding.yaml │ ├── external-dns-deployment.yaml │ ├── external-dns-serviceaccount.yaml │ └── kustomization.yaml ├── main.go ├── mkdocs.yml ├── pkg/ │ ├── OWNERS │ ├── apis/ │ │ ├── OWNERS │ │ └── externaldns/ │ │ ├── constants.go │ │ ├── types.go │ │ ├── types_test.go │ │ ├── validation/ │ │ │ ├── validation.go │ │ │ └── validation_test.go │ │ ├── version.go │ │ └── version_test.go │ ├── client/ │ │ ├── OWNERS │ │ ├── config.go │ │ └── config_test.go │ ├── events/ │ │ ├── OWNERS │ │ ├── controller.go │ │ ├── controller_test.go │ │ ├── fake/ │ │ │ ├── fake.go │ │ │ └── fake_test.go │ │ ├── types.go │ │ └── types_test.go │ ├── http/ │ │ ├── drain.go │ │ ├── drain_test.go │ │ ├── http.go │ │ ├── http_benchmark_test.go │ │ └── http_test.go │ ├── metrics/ │ │ ├── OWNERS │ │ ├── labels.go │ │ ├── metrics.go │ │ ├── metrics_test.go │ │ ├── models.go │ │ └── models_test.go │ ├── rfc2317/ │ │ ├── OWNERS │ │ ├── arpa.go │ │ └── arpa_test.go │ └── tlsutils/ │ ├── OWNERS │ ├── tlsconfig.go │ └── tlsconfig_test.go ├── plan/ │ ├── OWNERS │ ├── conflict.go │ ├── conflict_test.go │ ├── metrics.go │ ├── metrics_test.go │ ├── plan.go │ ├── plan_test.go │ ├── policy.go │ └── policy_test.go ├── provider/ │ ├── OWNERS │ ├── akamai/ │ │ ├── akamai.go │ │ └── akamai_test.go │ ├── alibabacloud/ │ │ ├── alibaba_cloud.go │ │ └── alibaba_cloud_test.go │ ├── aws/ │ │ ├── aws.go │ │ ├── aws_fixtures_test.go │ │ ├── aws_test.go │ │ ├── aws_utils_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ ├── fixtures/ │ │ │ └── 160-plus-zones.yaml │ │ ├── instrumented_config.go │ │ └── instrumented_config_test.go │ ├── awssd/ │ │ ├── aws_sd.go │ │ ├── aws_sd_test.go │ │ └── fixtures_test.go │ ├── azure/ │ │ ├── azure.go │ │ ├── azure_private_dns.go │ │ ├── azure_privatedns_test.go │ │ ├── azure_test.go │ │ ├── common.go │ │ ├── common_test.go │ │ ├── config.go │ │ ├── config_test.go │ │ └── fixtures/ │ │ └── config_test.json │ ├── blueprint/ │ │ ├── zone_cache.go │ │ └── zone_cache_test.go │ ├── cached_provider.go │ ├── cached_provider_test.go │ ├── civo/ │ │ ├── civo.go │ │ └── civo_test.go │ ├── cloudflare/ │ │ ├── OWNERS │ │ ├── cloudflare.go │ │ ├── cloudflare_batch.go │ │ ├── cloudflare_batch_test.go │ │ ├── cloudflare_custom_hostnames.go │ │ ├── cloudflare_custom_hostnames_test.go │ │ ├── cloudflare_regional.go │ │ ├── cloudflare_regional_test.go │ │ ├── cloudflare_test.go │ │ ├── pagination.go │ │ └── pagination_test.go │ ├── coredns/ │ │ ├── OWNERS │ │ ├── coredns.go │ │ └── coredns_test.go │ ├── dnsimple/ │ │ ├── dnsimple.go │ │ └── dnsimple_test.go │ ├── exoscale/ │ │ ├── exoscale.go │ │ └── exoscale_test.go │ ├── factory/ │ │ ├── provider.go │ │ └── provider_test.go │ ├── fakes/ │ │ └── provider.go │ ├── gandi/ │ │ ├── client.go │ │ ├── gandi.go │ │ └── gandi_test.go │ ├── godaddy/ │ │ ├── client.go │ │ ├── client_test.go │ │ ├── godaddy.go │ │ └── godaddy_test.go │ ├── google/ │ │ ├── google.go │ │ └── google_test.go │ ├── inmemory/ │ │ ├── inmemory.go │ │ └── inmemory_test.go │ ├── linode/ │ │ ├── linode.go │ │ └── linode_test.go │ ├── ns1/ │ │ ├── ns1.go │ │ └── ns1_test.go │ ├── oci/ │ │ ├── cache.go │ │ ├── cache_test.go │ │ ├── oci.go │ │ └── oci_test.go │ ├── ovh/ │ │ ├── ovh.go │ │ └── ovh_test.go │ ├── pdns/ │ │ ├── pdns.go │ │ └── pdns_test.go │ ├── pihole/ │ │ ├── client.go │ │ ├── clientV6.go │ │ ├── clientV6_test.go │ │ ├── client_test.go │ │ ├── pihole.go │ │ ├── piholeV6_test.go │ │ └── pihole_test.go │ ├── plural/ │ │ ├── client.go │ │ ├── plural.go │ │ └── plural_test.go │ ├── provider.go │ ├── provider_test.go │ ├── recordfilter.go │ ├── recordfilter_test.go │ ├── rfc2136/ │ │ ├── rfc2136.go │ │ └── rfc2136_test.go │ ├── scaleway/ │ │ ├── interface.go │ │ ├── scaleway.go │ │ └── scaleway_test.go │ ├── transip/ │ │ ├── transip.go │ │ └── transip_test.go │ ├── webhook/ │ │ ├── api/ │ │ │ ├── httpapi.go │ │ │ └── httpapi_test.go │ │ ├── webhook.go │ │ └── webhook_test.go │ ├── zone_id_filter.go │ ├── zone_id_filter_test.go │ ├── zone_tag_filter.go │ ├── zone_tag_filter_test.go │ ├── zone_type_filter.go │ ├── zone_type_filter_test.go │ ├── zonefinder.go │ └── zonefinder_test.go ├── registry/ │ ├── OWNERS │ ├── awssd/ │ │ ├── OWNERS │ │ ├── registry.go │ │ └── registry_test.go │ ├── dynamodb/ │ │ ├── OWNERS │ │ ├── registry.go │ │ └── registry_test.go │ ├── factory/ │ │ ├── registry.go │ │ └── registry_test.go │ ├── mapper/ │ │ ├── mapper.go │ │ └── mapper_test.go │ ├── noop/ │ │ ├── OWNERS │ │ ├── noop.go │ │ └── noop_test.go │ ├── registry.go │ └── txt/ │ ├── OWNERS │ ├── encryption_test.go │ ├── registry.go │ ├── registry_test.go │ └── utils_test.go ├── scripts/ │ ├── OWNERS │ ├── aws-cleanup-legacy-txt-records.py │ ├── e2e-test.sh │ ├── generate-crd.sh │ ├── get-sha256.sh │ ├── helm-tools.sh │ ├── install-ko.sh │ ├── install-tools.sh │ ├── releaser.sh │ ├── update_route53_k8s_txt_owner.py │ └── version-updater.sh ├── source/ │ ├── OWNERS │ ├── ambassador_host.go │ ├── ambassador_host_test.go │ ├── annotations/ │ │ ├── annotations.go │ │ ├── annotations_test.go │ │ ├── filter.go │ │ ├── filter_test.go │ │ ├── processors.go │ │ ├── processors_test.go │ │ ├── provider_specific.go │ │ └── provider_specific_test.go │ ├── compatibility.go │ ├── connector.go │ ├── connector_test.go │ ├── contour_httpproxy.go │ ├── contour_httpproxy_test.go │ ├── crd.go │ ├── crd_test.go │ ├── empty.go │ ├── empty_test.go │ ├── endpoint_benchmark_test.go │ ├── endpoints.go │ ├── endpoints_test.go │ ├── f5_transportserver.go │ ├── f5_transportserver_test.go │ ├── f5_virtualserver.go │ ├── f5_virtualserver_test.go │ ├── fake.go │ ├── fake_test.go │ ├── fqdn/ │ │ ├── fqdn.go │ │ └── fqdn_test.go │ ├── gateway.go │ ├── gateway_grpcroute.go │ ├── gateway_grpcroute_test.go │ ├── gateway_hostname.go │ ├── gateway_httproute.go │ ├── gateway_httproute_test.go │ ├── gateway_tcproute.go │ ├── gateway_tcproute_test.go │ ├── gateway_test.go │ ├── gateway_tlsroute.go │ ├── gateway_tlsroute_test.go │ ├── gateway_udproute.go │ ├── gateway_udproute_test.go │ ├── gloo_proxy.go │ ├── gloo_proxy_test.go │ ├── informers/ │ │ ├── fake.go │ │ ├── handlers.go │ │ ├── handlers_test.go │ │ ├── indexers.go │ │ ├── indexers_test.go │ │ ├── informers.go │ │ ├── informers_test.go │ │ ├── transfomers.go │ │ └── transformers_test.go │ ├── ingress.go │ ├── ingress_fqdn_test.go │ ├── ingress_test.go │ ├── istio_gateway.go │ ├── istio_gateway_fqdn_test.go │ ├── istio_gateway_test.go │ ├── istio_virtualservice.go │ ├── istio_virtualservice_fqdn_test.go │ ├── istio_virtualservice_test.go │ ├── kong_tcpingress.go │ ├── kong_tcpingress_test.go │ ├── main_test.go │ ├── node.go │ ├── node_fqdn_test.go │ ├── node_test.go │ ├── openshift_route.go │ ├── openshift_route_fqdn_test.go │ ├── openshift_route_test.go │ ├── pod.go │ ├── pod_fqdn_test.go │ ├── pod_indexer_test.go │ ├── pod_test.go │ ├── service.go │ ├── service_fqdn_test.go │ ├── service_test.go │ ├── shared_test.go │ ├── skipper_routegroup.go │ ├── skipper_routegroup_test.go │ ├── source.go │ ├── source_test.go │ ├── store.go │ ├── store_test.go │ ├── traefik_proxy.go │ ├── traefik_proxy_test.go │ ├── types/ │ │ └── types.go │ ├── unstructured.go │ ├── unstructured_converter.go │ ├── unstructured_fqdn_test.go │ ├── unstructured_test.go │ ├── utils.go │ ├── utils_test.go │ └── wrappers/ │ ├── dedupsource.go │ ├── dedupsource_test.go │ ├── multisource.go │ ├── multisource_test.go │ ├── nat64source.go │ ├── nat64source_test.go │ ├── post_processor.go │ ├── post_processor_test.go │ ├── source_test.go │ ├── targetfiltersource.go │ ├── targetfiltersource_test.go │ ├── types.go │ └── types_test.go └── tests/ └── integration/ ├── OWNERS ├── scenarios/ │ └── tests.yaml ├── source_test.go └── toolkit/ ├── mocks.go ├── models.go └── toolkit.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # EditorConfig helps developers define and maintain consistent # coding styles between different editors and IDEs # editorconfig.org root = true [*] # Change these settings to your own preference indent_style = space indent_size = 2 # We recommend you to keep these unchanged end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true # https://github.com/editorconfig/editorconfig-core-go/blob/master/.editorconfig [{Makefile,go.mod,go.sum,*.go}] indent_style = tab indent_size = 4 [*.py] indent_style = space indent_size = 4 ================================================ FILE: .github/ISSUE_TEMPLATE/---bug-report.md ================================================ --- name: "\U0001F41E Bug report" about: Report a bug encountered while operating external-dns title: '' labels: kind/bug assignees: '' --- **What happened**: **What you expected to happen**: **How to reproduce it (as minimally and precisely as possible)**: **Anything else we need to know?**: **Environment**: - External-DNS version (use `external-dns --version`): - DNS provider: - Others: ## Checklist - [ ] I have searched existing issues and tried to find a fix myself - [ ] I am using the [latest release](https://github.com/kubernetes-sigs/external-dns/releases), or have checked the [staging image](https://kubernetes-sigs.github.io/external-dns/latest/release/#staging-release-cycle) to confirm the bug is still reproducible - [ ] I have provided the actual process flags (not Helm values) - [ ] I have provided `kubectl get -o yaml` output including `status` - [ ] I have provided full external-dns debug logs - [ ] I have described what DNS records exist and what I expected ================================================ FILE: .github/ISSUE_TEMPLATE/--enhancement-request.md ================================================ --- name: "✨ Enhancement Request" about: Suggest an enhancement to external-dns title: '' labels: kind/feature assignees: '' --- **What would you like to be added**: **Why is this needed**: ================================================ FILE: .github/ISSUE_TEMPLATE/-support-request.md ================================================ --- name: "❓Support Request" about: Support request or question relating to external-dns title: '' labels: kind/support assignees: '' --- ================================================ FILE: .github/ISSUE_TEMPLATE/create-release.md ================================================ --- name: Create Release about: Release template to track the next release title: Release x.y labels: area/release assignees: '' --- This Issue tracks the next `external-dns` release. Please follow the guideline below. If anything is missing or unclear, please add a comment to this issue so this can be improved after the release. ## Preparation Tasks - [ ] Release [steps](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/release.md#steps) ### Release Execution - [ ] Branch out from the default branch and run scripts/version-updater.sh to update the image tag used in the kustomization.yaml and in documentation. - [ ] Create the PR with this version change. - [ ] Create an issue to release the corresponding Helm chart via the chart release process (below) assigned to a chart maintainer ### After Release Tasks - [ ] Announce release on `#external-dns` in Slack ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "gomod" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" groups: dev-dependencies: patterns: - "*" ignore: - dependency-name: "github.com/openshift/api" - dependency-name: "github.com/openshift/client-go" # miss tag on v1.19.0 in 2019 # See https://pkg.go.dev/github.com/exoscale/egoscale?tab=versions - dependency-name: "github.com/exoscale/egoscale" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" groups: dev-dependencies: patterns: - "*" # Dependencies listed in requirements.txt - package-ecosystem: "pip" directory: "docs/scripts" schedule: interval: "monthly" groups: mkdocs-deps: patterns: - "*" labels: - python - dependencies - ok-to-test - release-note-none ================================================ FILE: .github/labeler.yml ================================================ # Add 'docs' to any changes within 'docs' folder or any subfolders docs: - docs/**/* # Add 'provider/alibaba' in file which starts with alibaba provider/alibaba: provider/alibaba* # Add 'provider/aws' in file which starts with aws provider/aws: provider/aws* # Add 'provider/azure' in file which starts with azure provider/azure: provider/azure* # Add 'provider/bluecat' in file which starts with bluecat provider/bluecat: provider/bluecat* # Add 'provider/cloudflare' in file which starts with cloudflare provider/cloudflare: provider/cloudflare* # Add 'provider/coredns' in file which starts with coredns provider/coredns: provider/coredns* # Add 'provider/designate' in file which starts with designate provider/designate: provider/designate* # Add 'provider/dnssimple' in file which starts with dnssimple provider/dnssimple: provider/dnssimple* # Add 'provider/dyn' in file which starts with dyn provider/dyn: provider/dyn* # Add 'provider/exoscale' in file which starts with exoscale provider/exoscale: provider/exoscale* # Add 'provider/transip' in file which starts with transip provider/transip: provider/transip* # Add 'provider/rfc2136' in file which starts with rfc2136 provider/rfc2136: provider/rfc2136* # Add 'provider/rdns' in file which starts with rdns provider/rdns: provider/rdns* # Add 'provider/powerdns' in file which starts with pdns provider/powerdns: provider/pdns* # Add 'provider/google' in file which starts with google provider/google: provider/google* # Add 'provider/infoblox' in file which starts with infoblox provider/infoblox: provider/infoblox* # Add 'provider/linode' in file which starts with linode provider/linode: provider/linode* # Add 'provider/ns1' in file which starts with ns1 provider/ns1: provider/ns1* # Add 'provider/oci' in file which starts with oci provider/oci: provider/oci* # Add 'provider/vinyldns' in file which starts with vinyldns provider/vinyldns: provider/vinyldns* # Add 'provider/vultr' in file which starts with vultr provider/vultr: provider/vultr* # Add 'provider/ultradns' in file which starts with ultradns provider/ultradns: provider/ultradns* ================================================ FILE: .github/pull_request_template.md ================================================ ## What does it do ? ## Motivation ## More - [ ] Yes, this PR title follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] Yes, I added unit tests - [ ] Yes, I updated end user documentation accordingly ================================================ FILE: .github/renovate-config.js ================================================ "use strict"; // https://github.com/renovatebot/github-action/blob/main/.github/renovate.json // https://docs.renovatebot.com/configuration-options/ module.exports = { "extends": [":disableRateLimiting", ":semanticCommits"], "assigneesFromCodeOwners": true, "gitAuthor": "Renovate Bot ", "onboarding": false, "platform": "github", "repositories": [ "kubernetes-sigs/external-dns" ], "printConfig": false, "prConcurrentLimit": 0, "prHourlyLimit": 0, "stabilityDays": 3, "pruneStaleBranches": true, "recreateClosed": true, "dependencyDashboard": false, "requireConfig": false, "rebaseWhen": "behind-base-branch", "baseBranches": ["master", "main"], "recreateWhen": "always", "semanticCommits": "enabled", "pre-commit": { "enabled": true }, "labels": ["{{depType}}", "datasource::{{datasource}}", "type::{{updateType}}", "manager::{{manager}}"], // can be overridden per packageRule "addLabels": ["renovate-bot"], // cannot be overridden, any packageRule config extends this "packageRules": [ { "groupName": "pre-commit", "matchManagers": ["pre-commit"], "addLabels": ["pre-commit", "skip-release"] }, ], "enabledManagers": [ // supported managers https://docs.renovatebot.com/modules/manager/ "regex", "pre-commit" ], "customManagers": [ // https://docs.renovatebot.com/modules/manager/regex/ { // to capture registry.k8s.io/external-dns/external-dns: in *.md files "customType": "regex", "fileMatch": [ ".*\\.md$" ], "matchStrings": [ "(?registry.k8s.io\/external-dns\/external-dns):(?.*)" ], "depNameTemplate": "kubernetes-sigs/external-dns", "datasourceTemplate": "github-releases", "versioningTemplate": "semver" }, { "customType": "regex", "fileMatch": [".*"], "matchStrings": [ "datasource=(?.*?) depName=(?.*?)( versioning=(?.*?))?\\s.*?_VERSION=(?.*)\\s" ], "versioningTemplate": "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}", }, ] }; ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json" } ================================================ FILE: .github/workflows/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - github_actions ================================================ FILE: .github/workflows/ci.yaml ================================================ name: Go on: push: branches: [ master ] pull_request: branches: [ master ] permissions: contents: read # to fetch code (actions/checkout) jobs: test: permissions: contents: read # to fetch code (actions/checkout) checks: write # to create a new check based on the results (shogo82148/actions-goveralls) name: Test runs-on: ${{ matrix.os }} strategy: matrix: # tests for target OS os: [ubuntu-latest, macos-latest] steps: - name: Check out code into the Go module directory uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Go 1.x uses: actions/setup-go@v6.3.0 with: go-version-file: go.mod check-latest: true id: go - name: Install Dependencies run: | go get -v -t -d ./... - name: Test env: GOMAXPROCS: 4 GOMEMLIMIT: 8192MiB run: make go-test - name: Send coverage uses: coverallsapp/github-action@v2 with: file: profile.cov format: golang flag-name: run-${{ join(matrix.*, '-') }} parallel: true continue-on-error: true finish: needs: test if: ${{ always() }} runs-on: ubuntu-latest steps: - name: Coveralls Finished uses: coverallsapp/github-action@v2 with: parallel-finished: true carryforward: "run-ubuntu-latest,run-macos-latest" continue-on-error: true ================================================ FILE: .github/workflows/codeql-analysis.yaml ================================================ name: "CodeQL analysis" on: push: branches: [ master] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '35 13 * * 5' workflow_dispatch: jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] steps: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install go version uses: actions/setup-go@v6.3.0 with: go-version-file: go.mod # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main - run: | make build - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 ================================================ FILE: .github/workflows/dependency-update.yaml ================================================ name: update-versions-with-renovate on: push: branches: [main, master] schedule: # https://crontab.guru/ # once a day - cron: '0 0 * * *' jobs: update-versions-with-renovate: runs-on: ubuntu-latest if: github.repository == 'kubernetes-sigs/external-dns' steps: - name: checkout uses: actions/checkout@v6 # https://github.com/renovatebot/github-action - name: self-hosted renovate uses: renovatebot/github-action@v46.1.4 with: # https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication token: ${{ secrets.GITHUB_TOKEN }} configurationFile: .github/renovate-config.js env: LOG_LEVEL: info ================================================ FILE: .github/workflows/docs.yaml ================================================ name: Release Docs on: push: tags: - "v*" # See https://docs.github.com/fr/webhooks/webhook-events-and-payloads#workflow_dispatch # Can be used to update doc with latest tag workflow_dispatch: permissions: {} jobs: release_docs: permissions: contents: write # for mike to push name: Release Docs runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.12" cache: "pip" cache-dependency-path: "./docs/scripts/requirements.txt" - run: | pip install -r docs/scripts/requirements.txt - name: Configure Git user run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" - name: build and push run: | VERSION="${{ github.ref_name }}" if [[ ${{ github.event_name }} == "workflow_dispatch" ]]; then VERSION="latest" fi mike deploy $VERSION --push --update-aliases mike set-default --push latest ================================================ FILE: .github/workflows/end-to-end-tests.yml ================================================ name: end to end test on: push: branches: pull_request: branches: [ master ] workflow_dispatch: jobs: e2e-tests: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: e2e run: | ./scripts/e2e-test.sh ================================================ FILE: .github/workflows/gh-workflow-approve.yaml ================================================ name: Approve GH Workflows on: pull_request_target: types: - labeled - synchronize branches: - master jobs: approve: name: Approve ok-to-test if: contains(github.event.pull_request.labels.*.name, 'ok-to-test') runs-on: ubuntu-latest permissions: actions: write steps: - name: Update PR uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 continue-on-error: true with: github-token: ${{ secrets.GITHUB_TOKEN }} debug: ${{ secrets.ACTIONS_RUNNER_DEBUG == 'true' }} script: | const result = await github.rest.actions.listWorkflowRunsForRepo({ owner: context.repo.owner, repo: context.repo.repo, event: "pull_request", status: "action_required", head_sha: context.payload.pull_request.head.sha, per_page: 100 }); for (var run of result.data.workflow_runs) { await github.rest.actions.approveWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, run_id: run.id }); } ================================================ FILE: .github/workflows/json-yaml-validate.yml ================================================ name: json-yaml-validate on: push: branches: [ master ] pull_request: branches: [ master ] workflow_dispatch: permissions: contents: read pull-requests: write # enable write permissions for pull requests jobs: json-yaml-validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: json-yaml-validate uses: GrantBirki/json-yaml-validate@v4.0.0 with: # ref: https://github.com/GrantBirki/json-yaml-validate?tab=readme-ov-file#inputs- comment: "true" # enable comment mode yaml_exclude_regex: "(charts/external-dns/templates.*|mkdocs.yml)" allow_multiple_documents: "true" ================================================ FILE: .github/workflows/lint-test-chart.yaml ================================================ name: Lint and Test Chart on: pull_request: branches: - master paths: - "charts/external-dns/**" concurrency: group: chart-pr-${{ github.ref }} cancel-in-progress: true permissions: read-all jobs: lint-test: name: Lint and Test if: github.repository == 'kubernetes-sigs/external-dns' runs-on: ubuntu-latest permissions: contents: read defaults: run: shell: bash steps: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Install Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: latest - name: Configure Helm run: | set -euo pipefail helm plugin install https://github.com/losisin/helm-values-schema-json.git --verify=false helm plugin install https://github.com/helm-unittest/helm-unittest.git --verify=false - name: Run Helm Schema check working-directory: charts/external-dns run: | set -euo pipefail helm schema if [[ -n "$(git status --porcelain --untracked-files=no)" ]] then echo "Schema not up to date. Please run helm schema and commit changes!" >&2 exit 1 fi - name: Install Helm Docs uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6 with: github_token: ${{ secrets.GITHUB_TOKEN }} owner: norwoodj repository: helm-docs arch_amd64: x86_64 os_linux: Linux check_command: helm-docs --version version: latest - name: Run Helm Docs check run: | set -euo pipefail helm-docs if [[ -n "$(git status --porcelain --untracked-files=no)" ]] then echo "Documentation not up to date. Please run helm-docs and commit changes!" >&2 exit 1 fi - name: Run Helm Unit Tests run: | set -euo pipefail helm unittest -f 'tests/*_test.yaml' charts/external-dns - name: Install YQ uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6 with: github_token: ${{ github.token }} owner: mikefarah repository: yq extract: false filename_format: "{name}_{os}_{arch}" check_command: yq --version version: latest - name: Install MDQ uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6 with: github_token: ${{ github.token }} owner: yshavit repository: mdq arch_amd64: x64 filename_format: "{name}-{os}-{arch}.{ext}" check_command: mdq --version version: latest - name: Run CHANGELOG check run: | set -euo pipefail chart_file_path="./charts/external-dns/Chart.yaml" changelog_file_path="./charts/external-dns/CHANGELOG.md" version="$(yq eval '.version' "${chart_file_path}")" entry="$(mdq --no-br --link-format inline "# v${version}" <"${changelog_file_path}" || true)" if [[ -z "${entry}" ]] then echo "No CHANGELOG entry for ${chart} version ${version}!" >&2 exit 1 fi added="$(mdq --output plain "# v${version} | # Added | -" <"${changelog_file_path}" || true)" changed="$(mdq --output plain "# v${version} | # Changed | -" <"${changelog_file_path}" || true)" deprecated="$(mdq --output plain "# v${version} | # Deprecated | -" <"${changelog_file_path}" || true)" removed="$(mdq --output plain "# v${version} | # Removed | -" <"${changelog_file_path}" || true)" fixed="$(mdq --output plain "# v${version} | # Fixed | -" <"${changelog_file_path}" || true)" security="$(mdq --output plain "# v${version} | # Security | -" <"${changelog_file_path}" || true)" changes_path="./charts/external-dns/changes.txt" rm -f "${changes_path}" old_ifs="${IFS}" IFS=$'\n' for item in ${added}; do printf -- '- kind: added\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${changed}; do printf -- '- kind: changed\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${deprecated}; do printf -- '- kind: deprecated\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${removed}; do printf -- '- kind: removed\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${fixed}; do printf -- '- kind: fixed\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${security}; do printf -- '- kind: security\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done IFS="${old_ifs}" if [[ -f "${changes_path}" ]]; then echo "::group::Changes" cat "${changes_path}" echo "::endgroup::" changes="$(cat "${changes_path}")" yq eval --inplace '.annotations["artifacthub.io/changes"] |= strenv(changes)' "${chart_file_path}" rm -f "${changes_path}" fi - name: Install Artifact Hub CLI uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6 with: github_token: ${{ github.token }} owner: artifacthub repository: hub name: ah check_command: ah version version: latest - name: Run Artifact Hub lint run: ah lint --kind helm --path ./charts/external-dns || exit 1 - name: Install Python uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: token: ${{ github.token }} python-version: "3.x" - name: Set-up chart-testing uses: helm/chart-testing-action@6ec842c01de15ebb84c8627d2744a0c2f2755c9f # v2.8.0 - name: Run chart-testing lint run: ct lint --charts=./charts/external-dns --target-branch=${{ github.event.repository.default_branch }} --check-version-increment=false - name: Create Kind cluster uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0 with: wait: 120s - name: Run chart-testing install run: ct install --charts=./charts/external-dns --target-branch=${{ github.event.repository.default_branch }} ================================================ FILE: .github/workflows/lint.yaml ================================================ name: Lint on: pull_request: branches: [ master ] jobs: lint: name: Markdown and Go runs-on: ubuntu-latest permissions: # Required: allow read access to the content for analysis. contents: read # For go lang linter pull-requests: read steps: - name: Check out code into the Go module directory uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Lint markdown uses: nosborn/github-action-markdown-cli@v3.5.0 with: files: '.' config_file: ".markdownlint.json" - name: Set up Go uses: actions/setup-go@v6.3.0 with: go-version-file: go.mod - name: Go formatting run: | if [ -z "$(gofmt -l .)" ]; then echo -e "All '*.go' files are properly formatted." else echo -e "Please run 'make go-lint' to fix. Some files need formatting:" gofmt -d -l . exit 1 fi # https://github.com/golangci/golangci-lint-action?tab=readme-ov-file#verify - name: Verify linter configuration and Lint go code uses: golangci/golangci-lint-action@v9 with: verify: true args: --timeout=30m version: v2.7 - uses: actions/setup-python@v6 # https://github.com/pre-commit/action - name: Verify with pre-commit uses: pre-commit/action@v3.0.1 ================================================ FILE: .github/workflows/release-chart.yaml ================================================ name: Release Chart on: push: branches: - master paths: - "charts/external-dns/Chart.yaml" concurrency: group: chart-release cancel-in-progress: false permissions: read-all jobs: release: name: Release if: github.repository == 'kubernetes-sigs/external-dns' runs-on: ubuntu-latest permissions: contents: write defaults: run: shell: bash steps: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 - name: Install YQ uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6 with: github_token: ${{ github.token }} owner: mikefarah repository: yq extract: false filename_format: "{name}_{os}_{arch}" check_command: yq --version version: latest - name: Install MDQ uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6 with: github_token: ${{ github.token }} owner: yshavit repository: mdq arch_amd64: x64 filename_format: "{name}-{os}-{arch}.{ext}" check_command: mdq --version version: latest - name: Get chart version id: chart_version run: | set -euo pipefail chart_version="$(grep -Po "(?<=^version: ).+" charts/external-dns/Chart.yaml)" echo "version=${chart_version}" >> $GITHUB_OUTPUT - name: Get changelog entry id: changelog_reader uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 # v2.2.3 with: path: charts/external-dns/CHANGELOG.md version: "v${{ steps.chart_version.outputs.version }}" - name: Process changelog id: changelog run: | set -euo pipefail package_dir="./.cr-release-packages" mkdir -p "${package_dir}" release_notes_file="RELEASE.md" release_notes_path="./charts/external-dns/${release_notes_file}" cat <<"EOF" > "${release_notes_path}" ${{ steps.changelog_reader.outputs.changes }} EOF added="$(mdq --output plain '# Added | -' <"${release_notes_path}" || true)" changed="$(mdq --output plain '# Changed | -' <"${release_notes_path}" || true)" deprecated="$(mdq --output plain '# Deprecated | -' <"${release_notes_path}" || true)" removed="$(mdq --output plain '# Removed | -' <"${release_notes_path}" || true)" fixed="$(mdq --output plain '# Fixed | -' <"${release_notes_path}" || true)" security="$(mdq --output plain '# Security | -' <"${release_notes_path}" || true)" changes_path="./charts/external-dns/changes.txt" rm -f "${changes_path}" old_ifs="${IFS}" IFS=$'\n' for item in ${added}; do printf -- '- kind: added\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${changed}; do printf -- '- kind: changed\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${deprecated}; do printf -- '- kind: deprecated\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${removed}; do printf -- '- kind: removed\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${fixed}; do printf -- '- kind: fixed\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done for item in ${security}; do printf -- '- kind: security\n description: "%s"\n' "${item%.*}." >> "${changes_path}" done IFS="${old_ifs}" if [[ -f "${changes_path}" ]]; then changes="$(cat "${changes_path}")" yq eval --inplace '.annotations["artifacthub.io/changes"] |= strenv(changes)' ./charts/external-dns/Chart.yaml rm -f "${changes_path}" fi echo "release_notes_file=${release_notes_file}" >> "${GITHUB_OUTPUT}" - name: Install Artifact Hub CLI uses: action-stars/install-tool-from-github-release@1fa61c3bea52eca3bcdb1f5c961a3b113fe7fa54 # v0.2.6 with: github_token: ${{ github.token }} owner: artifacthub repository: hub name: ah check_command: ah version version: latest - name: Run Artifact Hub lint run: ah lint --kind helm --path ./charts/external-dns || exit 1 - name: Configure Git run: | git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - name: Install Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: latest - name: Run chart-releaser uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 env: CR_TOKEN: "${{ github.token }}" CR_RELEASE_NAME_TEMPLATE: "external-dns-helm-chart-{{ .Version }}" CR_RELEASE_NOTES_FILE: "${{ steps.changelog.outputs.release_notes_file }}" CR_MAKE_RELEASE_LATEST: "false" ================================================ FILE: .github/workflows/staging-image-tester.yaml ================================================ name: Build all images on: push: branches: [ master ] pull_request: branches: [ master ] permissions: contents: read # to fetch code (actions/checkout) jobs: build: permissions: contents: read # to fetch code (actions/checkout) checks: write # to create a new check based on the results (shogo82148/actions-goveralls) name: Build runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Go 1.x uses: actions/setup-go@v6.3.0 with: go-version-file: go.mod id: go - name: Install CI run: | go get -v -t -d ./... - name: Test run: make build.image/multiarch ================================================ FILE: .github/workflows/validate-crd.yml ================================================ name: Validate CRD Generation # This workflow validates that generated CRD files are up-to-date when tool # dependencies change. It ensures that if go.tool.mod or go.tool.sum are updated, # the corresponding generated files (CRDs and deepcopy code) are also regenerated # and committed in the same PR. # # Why this is needed: # - controller-gen (from go.tool.mod) generates CRD YAML and deepcopy Go code # - Different versions of controller-gen may produce different output # - When tool versions change, generated code must be regenerated # - This prevents CI failures and runtime issues from stale generated code on: pull_request: paths: - 'go.tool.mod' - 'go.tool.sum' - 'scripts/generate-crd.sh' - '**/dnsendpoints.externaldns.k8s.io.yaml' permissions: contents: read jobs: validate-crd: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Go uses: actions/setup-go@def8c394e3ad351a79bc93815e4a585520fe993b # v6.2.0 with: go-version-file: 'go.mod' - name: Regenerate CRDs run: ./scripts/generate-crd.sh - name: Check for uncommitted changes id: check_changes run: | # Check if there are any changes to generated files if ! git diff --quiet; then echo "::error::Generated CRD files are out of sync with go.tool.mod" echo "" echo "The following files have uncommitted changes after running 'make crd':" git diff . echo "" echo "This usually means:" echo "1. go.tool.mod or go.tool.sum was updated (new controller-gen version)" echo "2. The generated CRD files were not regenerated" echo "" echo "To fix this:" echo " make crd" echo " git diff ." echo " commit, push and update your PR:" exit 1 fi - name: Success if: success() run: | echo "✅ Generated CRD files are up-to-date" ================================================ FILE: .gitignore ================================================ # OSX leaves these everywhere on SMB shares ._* # OSX trash .DS_Store # Eclipse files .classpath .project .settings/** # Files generated by JetBrains IDEs, e.g. IntelliJ IDEA .idea/ *.iml # Vscode files .vscode __debug_* # This is where the result of the go build goes /output*/ /_output*/ /_output /build # Emacs save files *~ \#*\# .\#* # Vim-related files [._]*.s[a-w][a-z] [._]s[a-w][a-z] *.un~ Session.vim .netrwhist # cscope-related files cscope.* /bazel-* # coverage output cover.out coverage.html *.coverprofile external-dns # vendor dir vendor/ profile.cov # github codespaces .venv/ # Helm charts !/charts/external-dns/ docs/LICENSE.md docs/code-of-conduct.md docs/CONTRIBUTING.md docs/index.md docs/redirect site _scratch Pipfile ================================================ FILE: .golangci.yml ================================================ # https://golangci-lint.run/docs/configuration/ version: "2" linters: default: none enable: # golangci-lint help linters - copyloopvar # A linter detects places where loop variables are copied. https://golangci-lint.run/docs/linters/configuration/#copyloopvar - dogsled # Checks assignments with too many blank identifiers. https://golangci-lint.run/docs/linters/configuration/#dogsled - dupword # Duplicate word. https://golangci-lint.run/docs/linters/configuration/#dupword - goprintffuncname - govet - ineffassign - misspell - revive # https://golangci-lint.run/docs/linters/configuration/#revive - recvcheck # Checks for receiver type consistency. https://golangci-lint.run/docs/linters/configuration/#recvcheck - rowserrcheck # Checks whether Rows.Err of rows is checked successfully. - errchkjson # Checks types passed to the json encoding functions. ref: https://golangci-lint.run/docs/linters/configuration/#errchkjson - errorlint # Checking for unchecked errors in Go code https://golangci-lint.run/docs/linters/configuration/#errorlint - staticcheck - unconvert - unused # https://golangci-lint.run/docs/linters/configuration/#unused - unparam # https://golangci-lint.run/docs/linters/configuration/#unparam - usestdlibvars # A linter that detect the possibility to use variables/constants from the Go standard library. https://golangci-lint.run/docs/linters/configuration/#usestdlibvars - whitespace - decorder # Check declaration order and count of types, constants, variables and functions. https://golangci-lint.run/docs/linters/configuration/#decorder - tagalign # Check that struct tags are well aligned. https://golangci-lint.run/docs/linters/configuration/#tagalign - predeclared # Find code that shadows one of Go's predeclared identifiers - sloglint # Ensure consistent code style when using log/slog - asciicheck # Checks that all code identifiers does not have non-ASCII symbols in the name - nilnil # Checks that there is no simultaneous return of nil error and an nil value. ref: https://golangci-lint.run/docs/linters/configuration/#nilnil - nonamedreturns # Checks that functions with named return values do not return named values. https://golangci-lint.run/docs/linters/configuration/#nonamedreturns - cyclop # Checks function and package cyclomatic complexity. https://golangci-lint.run/docs/linters/configuration/#cyclop - gocritic # Analyze source code for various issues, including bugs, performance hiccups, and non-idiomatic coding practices. https://golangci-lint.run/docs/linters/configuration/#gocritic - gochecknoinits # Checks that there are no init() functions in the code. https://golangci-lint.run/docs/linters/configuration/#gochecknoinits - goconst # Finds repeated strings that could be replaced by a constant. https://golangci-lint.run/docs/linters/configuration/#goconst - modernize # A suite of analyzers that suggest simplifications to Go code, using modern language and library features. https://golangci-lint.run/docs/linters/configuration/#modernize # tests - testifylint # Checks usage of github.com/stretchr/testify. https://golangci-lint.run/docs/linters/configuration/#testifylint - usetesting # Reports uses of functions with replacement inside the testing package. settings: exhaustive: default-signifies-exhaustive: false misspell: locale: US revive: rules: - name: confusing-naming disabled: true - name: unused-parameter # https://github.com/mgechev/revive/blob/HEAD/RULES_DESCRIPTIONS.md#unused-parameter disabled: false cyclop: # Lower cyclomatic complexity threshold after the max complexity is lowered max-complexity: 32 # See https://github.com/kubernetes-sigs/external-dns/issues/5419 goconst: min-occurrences: 3 # Ignore well-known DNS record types, boolean strings, and common values ignore-string-values: - "^(A|AAAA|ALIAS|CNAME|MX|NS|PTR|SRV|TXT)$" # DNS record types - "^(true|false)$" # Boolean strings - "^none$" # Common null/empty indicator - "^(aws-sd|noop)$" # Registry types - can be ignored for consistency testifylint: # Enable all checkers (https://github.com/Antonboom/testifylint#checkers). # Default: false enable-all: true # Disable checkers by name # (in addition to default # suite-thelper # ). # TODO: enable in follow-up disable: - require-error usetesting: # Enable/disable `context.Background()` detections. context-background: true # Enable/disable `context.TODO()` detections. context-todo: true exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling rules: - linters: - deadcode - depguard - dogsled - goprintffuncname - govet - ineffassign - misspell - nolintlint - rowserrcheck - staticcheck - structcheck - unconvert - varcheck - whitespace - goconst path: _test\.go # TODO: skiip as will require design changes - linters: - nilnil path: istio_virtualservice.go|fqdn.go|cloudflare_custom_hostnames.go - linters: - gochecknoinits path: ^(internal/.*/init\.go|.*/metrics\.go|.*/webhook\.go|.*/http\.go|apis/.*\.go|.*/cached_provider\.go)$ - linters: - modernize path: ^(apis/.*\.go)$ paths: - endpoint/zz_generated.deepcopy.go - third_party$ - builtin$ - examples$ formatters: enable: - gofmt - goimports settings: goimports: local-prefixes: - sigs.k8s.io/external-dns exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: .ko.yaml ================================================ defaultBaseImage: gcr.io/distroless/static-debian12:latest builds: - env: - CGO_ENABLED=0 flags: - -v ldflags: - -s - -w - -X sigs.k8s.io/external-dns/pkg/apis/externaldns.Version={{.Env.VERSION}} ================================================ FILE: .markdownlint.json ================================================ { "default": true, "MD010": { "code_blocks": false }, "MD013": { "line_length": "300" }, "MD033": false, "MD036": false, "MD024": false, "MD041": false, "MD029": false, "MD034": false, "MD038": false, "MD046": false } ================================================ FILE: .pre-commit-config.yaml ================================================ --- default_language_version: node: system repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-shebang-scripts-are-executable - id: check-symlinks - id: destroyed-symlinks - id: end-of-file-fixer - id: fix-byte-order-marker - id: forbid-new-submodules - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.47.0 hooks: - id: markdownlint args: ["--fix"] minimum_pre_commit_version: !!str 3.2 ================================================ FILE: .spectral.yaml ================================================ extends: ["spectral:oas"] ================================================ FILE: .zappr.yaml ================================================ X-Zalando-Team: teapot ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Guidelines Welcome to Kubernetes. We are excited about the prospect of you joining our [community](https://git.k8s.io/community)! The Kubernetes community abides by the CNCF [code of conduct](code-of-conduct.md). Here is an excerpt: _In the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or other activities._ ## Getting Started We have full documentation on how to get started contributing here: - [Contributor License Agreement](https://git.k8s.io/community/CLA.md) Kubernetes projects require that you sign a Contributor License Agreement (CLA) before we can accept your pull requests - [Kubernetes Contributor Guide](https://git.k8s.io/community/contributors/guide) - Main contributor documentation, or you can just jump directly to the [contributing section](https://git.k8s.io/community/contributors/guide#contributing) - [Contributor Cheat Sheet](https://git.k8s.io/community/contributors/guide/contributor-cheatsheet) - Common resources for existing developers ## Developer Documentation For more detailed contribution guides, see [Developer Documentation](docs/contributing) which includes: - [Development Guide](docs/contributing/dev-guide.md) - Setting up development environment, building, and testing - [Chart Development](docs/contributing/chart.md) - Working with Helm charts - [Design Documentation](docs/contributing/design.md) - Architecture and design decisions - [Sources and Providers](docs/contributing/sources-and-providers.md) - Adding new sources and providers - [Source Wrappers](docs/contributing/source-wrappers.md) - Source wrapper implementation details This project follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification on PR title. The explicit commit history is used, among other things, to provide a readable changelog in release notes. ## How to test a PR On Linux (or WSL), a PR can be tested following this instruction with [gh](https://cli.github.com/) and [golang](https://go.dev/): ```bash gh repo clone kubernetes-sigs/external-dns cd external-dns gh pr checkout XXX # <=== Set PR number here go run main.go \ --kubeconfig= \ --log-format=text \ --log-level=debug \ --interval=1m --provider=xxx --source=yyy ``` ## Mentorship - [Mentoring Initiatives](https://git.k8s.io/community/mentoring) - We have a diverse set of mentorship programs available that are always looking for volunteers! ## Contact Information - [Slack channel](https://kubernetes.slack.com/messages/external-dns) - [Mailing list](https://groups.google.com/forum/#!forum/kubernetes-sig-network) ================================================ FILE: LICENSE.md ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright {yyyy} {name of copyright owner} 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. ================================================ FILE: Makefile ================================================ # Copyright 2017 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. #? cover: Creates coverage report for whole project excluding vendor and opens result in the default browser .PHONY: cover cover-html .DEFAULT_GOAL := build cover: @go test -cover -coverprofile=cover.out -v ./... #? cover-html: Run tests with coverage and open coverage report in the browser cover-html: cover @go tool cover -html=cover.out #? go-tools: list installed go tools go-tools: @echo ">> go tools installed in go.mod" @go tool -n @echo ">> go tools installed in go.tool.mod" @go tool -modfile=go.tool.mod #? golangci-lint-install: Install golangci-lint tool golangci-lint-install: @scripts/install-tools.sh --golangci #? go-lint: Run the golangci-lint tool .PHONY: go-lint go-lint: golangci-lint-install golangci-lint config verify gofmt -l -s -w . golangci-lint run --timeout=30m --fix ./... #? licensecheck: Run the to check for license headers .PHONY: licensecheck licensecheck: @echo ">> checking license header" @licRes=$$(for file in $$(find . -type f -iname '*.go' ! -path './vendor/*') ; do \ awk 'NR<=5' $$file | grep -Eq "(Copyright|generated|GENERATED)" || echo $$file; \ done); \ if [ -n "$${licRes}" ]; then \ echo "license header checking failed:"; echo "$${licRes}"; \ exit 1; \ fi #? lint: Run all the linters .PHONY: lint lint: licensecheck go-lint #? crd: Generates CRD using controller-gen and copy it into chart .PHONY: crd crd: @./scripts/generate-crd.sh # Required as long as dependabot does not support go.tool.mod https://github.com/dependabot/dependabot-core/issues/12050 #? update-tools-deps: Update go tools defined in go.tool.mod to latest versions update-tools-deps: @go get -modfile=go.tool.mod tool #? test: The verify target runs tasks similar to the CI tasks, but without code coverage .PHONY: test test: go test -race ./... .PHONY: test go-test: go test -race -coverprofile=profile.cov ./... go tool cover -func=profile.cov > coverage.summary @tail -n 1 coverage.summary #? build: The build targets allow to build the binary and container image .PHONY: build BINARY ?= external-dns SOURCES = $(shell find . -name '*.go') IMAGE_STAGING = gcr.io/k8s-staging-external-dns/$(BINARY) REGISTRY ?= us.gcr.io/k8s-artifacts-prod/external-dns IMAGE ?= $(REGISTRY)/$(BINARY) VERSION ?= $(shell git describe --tags --always --dirty --match "v*") GIT_COMMIT ?= $(shell git rev-parse --short HEAD) BUILD_FLAGS ?= -v LDFLAGS ?= -X sigs.k8s.io/external-dns/pkg/apis/externaldns.Version=$(VERSION) -w -s LDFLAGS += -X sigs.k8s.io/external-dns/pkg/apis/externaldns.GitCommit=$(GIT_COMMIT) ARCH ?= amd64 SHELL = /bin/bash IMG_PLATFORM ?= linux/amd64,linux/arm64,linux/arm/v7 IMG_PUSH ?= true IMG_SBOM ?= none build: build/$(BINARY) build/$(BINARY): $(SOURCES) CGO_ENABLED=0 go build -o build/$(BINARY) $(BUILD_FLAGS) -ldflags "$(LDFLAGS)" . build.push/multiarch: ko KO_DOCKER_REPO=${IMAGE} \ VERSION=${VERSION} \ ko build --tags ${VERSION} --bare --sbom ${IMG_SBOM} \ --image-label org.opencontainers.image.source="https://github.com/kubernetes-sigs/external-dns" \ --image-label org.opencontainers.image.revision=$(shell git rev-parse HEAD) \ --platform=${IMG_PLATFORM} --push=${IMG_PUSH} . build.image/multiarch: $(MAKE) IMG_PUSH=false build.push/multiarch build.image: $(MAKE) IMG_PLATFORM=linux/$(ARCH) build.image/multiarch build.image-amd64: $(MAKE) ARCH=amd64 build.image build.image-arm64: $(MAKE) ARCH=arm64 build.image build.image-arm/v7: $(MAKE) ARCH=arm/v7 build.image build.push: $(MAKE) IMG_PLATFORM=linux/$(ARCH) build.push/multiarch build.push-amd64: $(MAKE) ARCH=amd64 build.push build.push-arm64: $(MAKE) ARCH=arm64 build.push build.push-arm/v7: $(MAKE) ARCH=arm/v7 build.push build.arm64: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o build/$(BINARY) $(BUILD_FLAGS) -ldflags "$(LDFLAGS)" . build.amd64: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/$(BINARY) $(BUILD_FLAGS) -ldflags "$(LDFLAGS)" . build.arm/v7: CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -o build/$(BINARY) $(BUILD_FLAGS) -ldflags "$(LDFLAGS)" . clean: @rm -rf build @go clean -cache .PHONY: release.staging #? release.staging: Builds and push container images to the staging bucket. release.staging: test IMAGE=$(IMAGE_STAGING) $(MAKE) build.push/multiarch release.prod: test $(MAKE) build.push/multiarch .PHONY: ko ko: scripts/install-ko.sh .PHONY: generate-flags-documentation #? generate-flags-documentation: Generate documentation (docs/flags.md) generate-flags-documentation: go run internal/gen/docs/flags/main.go .PHONY: generate-metrics-documentation #? generate-metrics-documentation: Generate documentation (docs/monitoring/metrics.md) generate-metrics-documentation: go run internal/gen/docs/metrics/main.go .PHONY: generate-sources-documentation #? generate-sources-documentation: Generate documentation (docs/sources/index.md) generate-sources-documentation: go run internal/gen/docs/sources/main.go #? pre-commit-install: Install pre-commit hooks pre-commit-install: @pre-commit install @pre-commit gc #? pre-commit-uninstall: Uninstall pre-commit hooks pre-commit-uninstall: @pre-commit uninstall #? pre-commit-validate: Validate files with pre-commit hooks pre-commit-validate: @pre-commit run --all-files .PHONY: help #? help: Get more info on available commands help: Makefile @sed -n 's/^#?//p' $< | column -t -s ':' | sort | sed -e 's/^/ /' #? helm-test: Run unit tests helm-test: scripts/helm-tools.sh --helm-unittest #? helm-template: Run helm template helm-template: scripts/helm-tools.sh --helm-template #? helm-lint: Run helm linting (schema,docs) helm-lint: scripts/helm-tools.sh --schema scripts/helm-tools.sh --docs .PHONY: go-dependency #? go-dependency: Dependency maintanance go-dependency: go mod tidy .PHONY: mkdocs-serve #? mkdocs-serve: Run the builtin development server for mkdocs mkdocs-serve: @$(info "contribute to documentation docs/contributing/dev-guide.md") @mkdocs serve ================================================ FILE: OWNERS ================================================ # See the OWNERS file documentation: # https://github.com/kubernetes/community/blob/HEAD/contributors/guide/owners.md ## These OWNERS files should stay in sync: # https://github.com/kubernetes/test-infra/blob/master/config/jobs/kubernetes-sigs/external-dns/OWNERS # https://github.com/kubernetes/k8s.io/blob/master/registry.k8s.io/images/k8s-staging-external-dns/OWNERS ## with this GitHub teams file: # https://github.com/kubernetes/org/blob/main/config/kubernetes-sigs/sig-network/teams.yaml approvers: - ivankatliarchuk - mloiseleur - raffo - szuecs reviewers: - ivankatliarchuk - mloiseleur - raffo - szuecs - vflaux emeritus_approvers: - hjacobs - johngmyers - linki - njuettner - seanmalloy ================================================ FILE: README.md ================================================ --- hide: - toc - navigation ---

ExternalDNS

# ExternalDNS [![Build Status](https://github.com/kubernetes-sigs/external-dns/workflows/Go/badge.svg)](https://github.com/kubernetes-sigs/external-dns/actions) [![Coverage Status](https://coveralls.io/repos/github/kubernetes-sigs/external-dns/badge.svg)](https://coveralls.io/github/kubernetes-sigs/external-dns) [![GitHub release](https://img.shields.io/github/release/kubernetes-sigs/external-dns.svg)](https://github.com/kubernetes-sigs/external-dns/releases) [![go-doc](https://godoc.org/github.com/kubernetes-sigs/external-dns?status.svg)](https://godoc.org/github.com/kubernetes-sigs/external-dns) [![Go Report Card](https://goreportcard.com/badge/github.com/kubernetes-sigs/external-dns)](https://goreportcard.com/report/github.com/kubernetes-sigs/external-dns) [![ExternalDNS docs](https://img.shields.io/badge/docs-external--dns-blue)](https://kubernetes-sigs.github.io/external-dns/) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/kubernetes-sigs/external-dns) ExternalDNS synchronizes exposed Kubernetes Services and Ingresses with DNS providers. ## Documentation This README is a part of the complete [documentation, available here](https://kubernetes-sigs.github.io/external-dns/) and [DeepWiki](https://deepwiki.com/kubernetes-sigs/external-dns). ## What It Does Inspired by [Kubernetes DNS](https://github.com/kubernetes/dns), Kubernetes' cluster-internal DNS server, ExternalDNS makes Kubernetes resources discoverable via public DNS servers. Like KubeDNS, it retrieves a list of resources (Services, Ingresses, etc.) from the [Kubernetes API](https://kubernetes.io/docs/api/) to determine a desired list of DNS records. _Unlike_ KubeDNS, however, it's not a DNS server itself, but merely configures other DNS providers accordingly—e.g. [AWS Route 53](https://aws.amazon.com/route53/) or [Google Cloud DNS](https://cloud.google.com/dns/docs/). In a broader sense, ExternalDNS allows you to control DNS records dynamically via Kubernetes resources in a DNS provider-agnostic way. The [FAQ](docs/faq.md) contains additional information and addresses several questions about key concepts of ExternalDNS. To see ExternalDNS in action, have a look at this [video](https://www.youtube.com/watch?v=9HQ2XgL9YVI) or read this [blogpost](https://codemine.be/posts/20190125-devops-eks-externaldns/). ## The Latest Release - [current release process](./docs/release.md) ExternalDNS allows you to keep selected zones (via `--domain-filter`) synchronized with Ingresses and Services of `type=LoadBalancer` and nodes in various DNS providers: - [Google Cloud DNS](https://cloud.google.com/dns/docs/) - [AWS Route 53](https://aws.amazon.com/route53/) - [AWS Cloud Map](https://docs.aws.amazon.com/cloud-map/) - [AzureDNS](https://azure.microsoft.com/en-us/services/dns) - [Civo](https://www.civo.com) - [CloudFlare](https://www.cloudflare.com/dns) - [DNSimple](https://dnsimple.com/) - [PowerDNS](https://www.powerdns.com/) - [CoreDNS](https://coredns.io/) - [Exoscale](https://www.exoscale.com/dns/) - [Oracle Cloud Infrastructure DNS](https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm) - [Linode DNS](https://www.linode.com/docs/networking/dns/) - [RFC2136](https://tools.ietf.org/html/rfc2136) - [NS1](https://ns1.com/) - [TransIP](https://www.transip.eu/domain-name/) - [OVHcloud](https://www.ovhcloud.com) - [Scaleway](https://www.scaleway.com) - [Akamai Edge DNS](https://learn.akamai.com/en-us/products/cloud_security/edge_dns.html) - [GoDaddy](https://www.godaddy.com) - [Gandi](https://www.gandi.net) - [Plural](https://www.plural.sh/) - [Pi-hole](https://pi-hole.net/) - [Alibaba Cloud DNS](https://www.alibabacloud.com/help/en/dns) - [Myra Security DNS](https://www.myrasecurity.com/en/saasp/application-security/secure-dns/) ExternalDNS is, by default, aware of the records it is managing, therefore it can safely manage non-empty hosted zones. We strongly encourage you to set `--txt-owner-id` to a unique value that doesn't change for the lifetime of your cluster. You might also want to run ExternalDNS in a dry run mode (`--dry-run` flag) to see the changes to be submitted to your DNS Provider API. Note that all flags can be replaced with environment variables; for instance, `--dry-run` could be replaced with `EXTERNAL_DNS_DRY_RUN=1`. ## New providers No new provider will be added to ExternalDNS _in-tree_. ExternalDNS has introduced a webhook system, which can be used to add a new provider. See PR #3063 for all the discussions about it. Some known providers using webhooks are the ones in the table below. **NOTE**: The maintainers of ExternalDNS have not reviewed those providers, use them at your own risk and following the license and usage recommendations provided by the respective projects. The maintainers of ExternalDNS take no responsibility for any issue or damage from the usage of any externally developed webhook. | Provider | Repo | | --------------------- | -------------------------------------------------------------------- | | Abion | https://github.com/abiondevelopment/external-dns-webhook-abion | | Adguard Home Provider | https://github.com/muhlba91/external-dns-provider-adguard | | Anexia | https://github.com/anexia/k8s-external-dns-webhook | | Bizfly Cloud | https://github.com/bizflycloud/external-dns-bizflycloud-webhook | | ClouDNS | https://github.com/rwunderer/external-dns-cloudns-webhook | | deSEC | https://github.com/michelangelomo/external-dns-desec-provider | | DigitalOcean | https://github.com/amoniacou/external-dns-digitalocean-webhook | | Dreamhost | https://github.com/asymingt/external-dns-dreamhost-webhook | | Efficient IP | https://github.com/EfficientIP-Labs/external-dns-efficientip-webhook | | Gcore | https://github.com/G-Core/external-dns-gcore-webhook | | GleSYS | https://github.com/glesys/external-dns-glesys | | Hetzner | https://github.com/mconfalonieri/external-dns-hetzner-webhook | | Huawei Cloud | https://github.com/setoru/external-dns-huaweicloud-webhook | | IONOS | https://github.com/ionos-cloud/external-dns-ionos-webhook | | Infoblox | https://github.com/AbsaOSS/external-dns-infoblox-webhook | | Infomaniak | https://github.com/M0NsTeRRR/external-dns-webhook-infomaniak | | Mikrotik | https://github.com/mirceanton/external-dns-provider-mikrotik | | Myra Security | https://github.com/Myra-Security-GmbH/external-dns-myrasec-webhook | | Netcup | https://github.com/mrueg/external-dns-netcup-webhook | | Netic | https://github.com/neticdk/external-dns-tidydns-webhook | | OpenStack Designate | https://github.com/inovex/external-dns-designate-webhook | | OpenWRT | https://github.com/renanqts/external-dns-openwrt-webhook | | PS Cloud Services | https://github.com/supervillain3000/external-dns-pscloud-webhook | | SAKURA Cloud | https://github.com/sacloud/external-dns-sacloud-webhook | | Simply | https://github.com/uozalp/external-dns-simply-webhook | | STACKIT | https://github.com/stackitcloud/external-dns-stackit-webhook | | Unbound | https://github.com/guillomep/external-dns-unbound-webhook | | Unifi | https://github.com/kashalls/external-dns-unifi-webhook | | UniFi | https://github.com/lexfrei/external-dns-unifios-webhook | | Volcengine Cloud | https://github.com/volcengine/external-dns-volcengine-webhook | | Vultr | https://github.com/vultr/external-dns-vultr-webhook | | Yandex Cloud | https://github.com/ismailbaskin/external-dns-yandex-webhook/ | ## Status of in-tree providers ExternalDNS supports multiple DNS providers which have been implemented by the [ExternalDNS contributors](https://github.com/kubernetes-sigs/external-dns/graphs/contributors). Maintaining all of those in a central repository is a challenge, which introduces lots of toil and potential risks. This mean that `external-dns` has begun the process to move providers out of tree. See #4347 for more details. Those who are interested can create a webhook provider based on an _in-tree_ provider and after submit a PR to reference it here. We define the following stability levels for providers: - **Stable**: Used for smoke tests before a release, used in production and maintainers are active. - **Beta**: Community supported, well tested, but maintainers have no access to resources to execute integration tests on the real platform and/or are not using it in production. - **Alpha**: Community provided with no support from the maintainers apart from reviewing PRs. The following table clarifies the current status of the providers according to the aforementioned stability levels: | Provider | Status | Maintainers | |---------------------------------| ------ |------------------| | Google Cloud DNS | Stable | | | AWS Route 53 | Stable | | | AWS Cloud Map | Beta | | | Akamai Edge DNS | Beta | | | AzureDNS | Stable | | | Civo | Alpha | @alejandrojnm | | CloudFlare | Beta | | | DNSimple | Alpha | | | PowerDNS | Alpha | | | CoreDNS | Alpha | | | Exoscale | Alpha | | | Oracle Cloud Infrastructure DNS | Alpha | | | Linode DNS | Alpha | | | RFC2136 | Alpha | | | NS1 | Alpha | | | TransIP | Alpha | | | OVHcloud | Beta | @rbeuque74 | | Scaleway DNS | Alpha | @Sh4d1 | | GoDaddy | Alpha | | | Gandi | Alpha | @packi | | Plural | Alpha | @michaeljguarino | | Pi-hole | Alpha | @tinyzimmer | | Alibaba Cloud DNS | Alpha | | ## Kubernetes version compatibility Breaking changes were introduced in external-dns in the following versions: - [`v0.10.0`](https://github.com/kubernetes-sigs/external-dns/releases/tag/v0.10.0): use of `networking.k8s.io/ingresses` instead of `extensions/ingresses` (see [#2281](https://github.com/kubernetes-sigs/external-dns/pull/2281)) - [`v0.18.0`](https://github.com/kubernetes-sigs/external-dns/releases/tag/v0.18.0): use of `discovery.k8s.io/endpointslices` instead of `endpoints` (see [#5493](https://github.com/kubernetes-sigs/external-dns/pull/5493)) - [`v0.19.0`](https://github.com/kubernetes-sigs/external-dns/releases/tag/v0.19.0): don't expose internal ipv6 by default (see [#5575](https://github.com/kubernetes-sigs/external-dns/pull/5575)) and disable legacy listeners on `traefik.containo.us` API Group (see [#5565](https://github.com/kubernetes-sigs/external-dns/pull/5565)) | ExternalDNS | ≤ 0.9.x | ≥ 0.10.x and ≤ 0.17.x | ≥ 0.18.x | | ---------------------------- | :----------------: | :-------------------: | :----------------: | | Kubernetes ≤ 1.18 | :white_check_mark: | :x: | :x: | | Kubernetes 1.19 and 1.20 | :white_check_mark: | :white_check_mark: | :x: | | Kubernetes 1.21 | :white_check_mark: | :white_check_mark: | :white_check_mark: | | Kubernetes ≥ 1.22 and ≤ 1.32 | :x: | :white_check_mark: | :white_check_mark: | | Kubernetes ≥ 1.33 | :x: | :x: | :white_check_mark: | ## Running ExternalDNS There are two ways of running ExternalDNS: - Deploying to a Cluster - Running Locally ### Deploying to a Cluster The following tutorials are provided: - [Akamai Edge DNS](docs/tutorials/akamai-edgedns.md) - [Alibaba Cloud](docs/tutorials/alibabacloud.md) - AWS - [AWS Load Balancer Controller](docs/tutorials/aws-load-balancer-controller.md) - [Route53](docs/tutorials/aws.md) - [Same domain for public and private Route53 zones](docs/tutorials/aws-public-private-route53.md) - [Cloud Map](docs/tutorials/aws-sd.md) - [Kube Ingress AWS Controller](docs/tutorials/kube-ingress-aws.md) - [Azure DNS](docs/tutorials/azure.md) - [Azure Private DNS](docs/tutorials/azure-private-dns.md) - [Civo](docs/tutorials/civo.md) - [Cloudflare](docs/tutorials/cloudflare.md) - [CoreDNS](docs/tutorials/coredns.md) - [DNSimple](docs/tutorials/dnsimple.md) - [Exoscale](docs/tutorials/exoscale.md) - [ExternalName Services](docs/tutorials/externalname.md) - Google Kubernetes Engine - [Using Google's Default Ingress Controller](docs/tutorials/gke.md) - [Using the Nginx Ingress Controller](docs/tutorials/gke-nginx.md) - [Headless Services](docs/tutorials/hostport.md) - [IONOS Cloud](docs/tutorials/ionoscloud.md) - [Istio Gateway Source](docs/sources/istio.md) - [Linode](docs/tutorials/linode.md) - [Myra Security](docs/tutorials/myra.md) - [NS1](docs/tutorials/ns1.md) - [NS Record Creation with CRD Source](docs/sources/ns-record.md) - [MX Record Creation with CRD Source](docs/sources/mx-record.md) - [TXT Record Creation with CRD Source](docs/sources/txt-record.md) - [Oracle Cloud Infrastructure (OCI) DNS](docs/tutorials/oracle.md) - [PowerDNS](docs/tutorials/pdns.md) - [RFC2136](docs/tutorials/rfc2136.md) - [TransIP](docs/tutorials/transip.md) - [OVHcloud](docs/tutorials/ovh.md) - [Scaleway](docs/tutorials/scaleway.md) - [GoDaddy](docs/tutorials/godaddy.md) - [Gandi](docs/tutorials/gandi.md) - [Nodes as source](docs/sources/nodes.md) - [Plural](docs/tutorials/plural.md) - [Pi-hole](docs/tutorials/pihole.md) ### Running Locally See the [contributor guide](docs/contributing/dev-guide.md) for details on compiling from source. #### Setup Steps Next, run an application and expose it via a Kubernetes Service: ```console kubectl run nginx --image=nginx --port=80 kubectl expose pod nginx --port=80 --target-port=80 --type=LoadBalancer ``` Annotate the Service with your desired external DNS name. Make sure to change `example.org` to your domain. ```console kubectl annotate service nginx "external-dns.alpha.kubernetes.io/hostname=nginx.example.org." ``` Optionally, you can customize the TTL value of the resulting DNS record by using the `external-dns.alpha.kubernetes.io/ttl` annotation: ```console kubectl annotate service nginx "external-dns.alpha.kubernetes.io/ttl=10" ``` For more details on configuring TTL, see [advanced ttl](docs/advanced/ttl.md). Use the internal-hostname annotation to create DNS records with ClusterIP as the target. ```console kubectl annotate service nginx "external-dns.alpha.kubernetes.io/internal-hostname=nginx.internal.example.org." ``` If the service is not of type Loadbalancer you need the --publish-internal-services flag. Locally run a single sync loop of ExternalDNS. ```console external-dns --txt-owner-id my-cluster-id --provider google --google-project example-project --source service --once --dry-run ``` This should output the DNS records it will modify to match the managed zone with the DNS records you desire. It also assumes you are running in the `default` namespace. See the [FAQ](docs/faq.md) for more information regarding namespaces. Note: TXT records will have the `my-cluster-id` value embedded. Those are used to ensure that ExternalDNS is aware of the records it manages. Once you're satisfied with the result, you can run ExternalDNS like you would run it in your cluster: as a control loop, and **not in dry-run** mode: ```console external-dns --txt-owner-id my-cluster-id --provider google --google-project example-project --source service ``` Check that ExternalDNS has created the desired DNS record for your Service and that it points to its load balancer's IP. Then try to resolve it: ```console dig +short nginx.example.org. 104.155.60.49 ``` Now you can experiment and watch how ExternalDNS makes sure that your DNS records are configured as desired. Here are a couple of things you can try out: - Change the desired hostname by modifying the Service's annotation. - Recreate the Service and see that the DNS record will be updated to point to the new load balancer IP. - Add another Service to create more DNS records. - Remove Services to clean up your managed zone. The **tutorials** section contains examples, including Ingress resources, and shows you how to set up ExternalDNS in different environments such as other cloud providers and alternative Ingress controllers. # Note If using a txt registry and attempting to use a CNAME the `--txt-prefix` must be set to avoid conflicts. Changing `--txt-prefix` will result in lost ownership over previously created records. If `externalIPs` list is defined for a `LoadBalancer` service, this list will be used instead of an assigned load balancer IP to create a DNS record. It's useful when you run bare metal Kubernetes clusters behind NAT or in a similar setup, where a load balancer IP differs from a public IP (e.g. with [MetalLB](https://metallb.universe.tf)). ## Contributing Are you interested in contributing to external-dns? We, the maintainers and community, would love your suggestions, contributions, and help! Also, the maintainers can be contacted at any time to learn more about how to get involved. We also encourage ALL active community participants to act as if they are maintainers, even if you don't have "official" write permissions. This is a community effort, we are here to serve the Kubernetes community. If you have an active interest and you want to get involved, you have real power! Don't assume that the only people who can get things done around here are the "maintainers". We also would love to add more "official" maintainers, so show us what you can do! The external-dns project is currently in need of maintainers for specific DNS providers. Ideally each provider would have at least two maintainers. It would be nice if the maintainers run the provider in production, but it is not strictly required. Provider listed [status](https://github.com/kubernetes-sigs/external-dns#status-of-in-tree-providers) that do not have a maintainer listed are in need of assistance. Read the [contributing guidelines](CONTRIBUTING.md) and have a look at [the contributing docs](docs/contributing/dev-guide.md) to learn about building the project, the project structure, and the purpose of each package. For an overview on how to write new Sources and Providers check out [Sources and Providers](docs/contributing/sources-and-providers.md). ## Heritage ExternalDNS is an effort to unify the following similar projects in order to bring the Kubernetes community an easy and predictable way of managing DNS records across cloud providers based on their Kubernetes resources: - Kops' [DNS Controller](https://github.com/kubernetes/kops/tree/HEAD/dns-controller) - Zalando's [Mate](https://github.com/linki/mate) - Molecule Software's [route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes) ### User Demo How-To Blogs and Examples - A full demo on GKE Kubernetes. See [How-to Kubernetes with DNS management (ssl-manager pre-req)](https://medium.com/@jpantjsoha/how-to-kubernetes-with-dns-management-for-gitops-31239ea75d8d) - Run external-dns on GKE with workload identity. See [Kubernetes, ingress-nginx, cert-manager & external-dns](https://blog.atomist.com/kubernetes-ingress-nginx-cert-manager-external-dns/) - [ExternalDNS integration with Azure DNS using workload identity](https://cloudchronicles.blog/blog/ExternalDNS-integration-with-Azure-DNS-using-workload-identity/) ================================================ FILE: SECURITY_CONTACTS ================================================ # Defined below are the security contacts for this repo. # # They are the contact point for the Product Security Team to reach out # to for triaging and handling of incoming issues. # # The below names agree to abide by the # [Embargo Policy](https://github.com/kubernetes/sig-release/blob/HEAD/security-release-process-documentation/security-release-process.md#embargo-policy) # and will be removed and replaced if they violate that agreement. # # DO NOT REPORT SECURITY VULNERABILITIES DIRECTLY TO THESE NAMES, FOLLOW THE # INSTRUCTIONS AT https://kubernetes.io/security/ njuettner hjacobs raffo ================================================ FILE: api/webhook.yaml ================================================ --- openapi: "3.0.0" info: version: v0.15.0 title: External DNS Webhook Server description: >- Implements the external DNS webhook endpoints. contact: url: https://github.com/kubernetes-sigs/external-dns license: name: Apache 2.0 url: https://www.apache.org/licenses/LICENSE-2.0.html tags: - name: initialization description: Endpoints for initial negotiation. - name: listing description: Endpoints to get listings of DNS records. - name: update description: Endpoints to update DNS records. servers: - url: http://localhost:8888 description: Server url for a Kubernetes deployment. paths: /: get: summary: >- Initialisation and negotiates headers and returns domain filter. description: | Initialisation and negotiates headers and returns domain filter. operationId: negotiate tags: [initialization] responses: '200': description: | The list of domains this DNS provider serves. content: application/external.dns.webhook+json;version=1: schema: $ref: '#/components/schemas/filters' example: filters: - example.com '500': description: | Negotiation failed. /records: get: summary: Returns the current records. description: | Get the current records from the DNS provider and return them. operationId: getRecords tags: [listing] responses: '200': description: | Provided the list of DNS records successfully. content: application/external.dns.webhook+json;version=1: schema: $ref: '#/components/schemas/endpoints' example: - dnsName: "test.example.com" recordTTL: 10 recordType: 'A' targets: - "1.2.3.4" '500': description: | Failed to provide the list of DNS records. post: summary: Applies the changes. description: | Set the records in the DNS provider based on those supplied here. operationId: setRecords tags: [update] requestBody: description: | This is the list of changes that need to be applied. There are four lists of endpoints. The `create` and `delete` lists are lists of records to create and delete respectively. The `updateOld` and `updateNew` lists are paired. For each entry there's the old version of the record and a new version of the record. required: true content: application/external.dns.webhook+json;version=1: schema: $ref: '#/components/schemas/changes' example: create: - dnsName: "test.example.com" recordTTL: 10 recordType: 'A' responses: '204': description: | Changes were accepted. '500': description: | Changes were not accepted. /adjustendpoints: post: summary: Executes the AdjustEndpoints method. description: | Adjusts the records in the provider based on those supplied here. operationId: adjustRecords tags: [update] requestBody: description: | This is the list of changes to be applied. required: true content: application/external.dns.webhook+json;version=1: schema: $ref: '#/components/schemas/endpoints' example: - dnsName: "test.example.com" recordTTL: 10 recordType: 'A' targets: - "1.2.3.4" responses: '200': description: | Adjustments were accepted. content: application/external.dns.webhook+json;version=1: schema: $ref: '#/components/schemas/endpoints' example: - dnsName: "test.example.com" recordTTL: 0 recordType: 'A' targets: - "1.2.3.4" '500': description: | Adjustments were not accepted. components: schemas: filters: description: | external-dns will only create DNS records for host names (specified in ingress objects and services with the external-dns annotation) related to zones that match filters. They can set in external-dns deployment manifest. type: object properties: filters: type: array items: type: string example: "foo.example.com" example: - ".example.com" example: filters: - ".example.com" - ".example.org" endpoints: description: | This is a list of DNS records. type: array items: $ref: '#/components/schemas/endpoint' example: - dnsName: foo.example.com recordType: A recordTTL: 60 endpoint: description: | This is a DNS record. type: object properties: dnsName: type: string example: "foo.example.org" targets: $ref: '#/components/schemas/targets' recordType: type: string example: "CNAME" setIdentifier: type: string example: "v1" recordTTL: type: integer format: int64 example: 60 labels: type: object additionalProperties: type: string example: "foo" example: foo: bar providerSpecific: type: array items: $ref: '#/components/schemas/providerSpecificProperty' example: - name: foo value: bar example: dnsName: foo.example.com recordType: A recordTTL: 60 targets: description: | This is the list of targets that this DNS record points to. So for an A record it will be a list of IP addresses. type: array items: type: string example: "::1" example: - "1.2.3.4" - "test.example.org" providerSpecificProperty: description: | Allows provider to pass property specific to their implementation. type: object properties: name: type: string example: foo value: type: string example: bar example: name: foo value: bar changes: description: | This is the list of changes send by `external-dns` that need to be applied. There are four lists of endpoints. The `create` and `delete` lists are lists of records to create and delete respectively. The `updateOld` and `updateNew` lists are paired. For each entry there's the old version of the record and a new version of the record. type: object properties: create: $ref: '#/components/schemas/endpoints' updateOld: $ref: '#/components/schemas/endpoints' updateNew: $ref: '#/components/schemas/endpoints' delete: $ref: '#/components/schemas/endpoints' example: create: - dnsName: foo.example.com recordType: A recordTTL: 60 delete: - dnsName: foo.example.org recordType: CNAME ================================================ FILE: apis/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - apis ================================================ FILE: apis/api.go ================================================ /* 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 apis ================================================ FILE: apis/v1alpha1/api.go ================================================ /* Copyright 2017 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 contains API Schema definitions for the externaldns.k8s.io v1alpha1 API group // +kubebuilder:object:generate=true // +groupName=externaldns.k8s.io package v1alpha1 ================================================ FILE: apis/v1alpha1/dnsendpoint.go ================================================ /* Copyright 2017 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 ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/endpoint" ) // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // DNSEndpoint is a contract that a user-specified CRD must implement to be used as a source for external-dns. // The user-specified CRD should also have the status sub-resource. // +k8s:openapi-gen=true // +groupName=externaldns.k8s.io // +kubebuilder:resource:path=dnsendpoints // +kubebuilder:subresource:status // +kubebuilder:metadata:annotations="api-approved.kubernetes.io=https://github.com/kubernetes-sigs/external-dns/pull/2007" // +versionName=v1alpha1 type DNSEndpoint struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec DNSEndpointSpec `json:"spec,omitempty"` Status DNSEndpointStatus `json:"status,omitempty"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // DNSEndpointList is a list of DNSEndpoint objects type DNSEndpointList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` Items []DNSEndpoint `json:"items"` } // DNSEndpointSpec defines the desired state of DNSEndpoint type DNSEndpointSpec struct { Endpoints []*endpoint.Endpoint `json:"endpoints,omitempty"` } // DNSEndpointStatus defines the observed state of DNSEndpoint type DNSEndpointStatus struct { // The generation observed by the external-dns controller. // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` } ================================================ FILE: apis/v1alpha1/groupversion_info.go ================================================ /* Copyright 2017 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 contains API Schema definitions for the externaldns.k8s.io v1alpha1 API group // +kubebuilder:object:generate=true // +groupName=externaldns.k8s.io package v1alpha1 import ( "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/controller-runtime/pkg/scheme" ) const ( // DNSEndpointKind is the kind name for DNSEndpoint resources DNSEndpointKind = "DNSEndpoint" ) var ( // GroupVersion is group version used to register these objects GroupVersion = schema.GroupVersion{Group: "externaldns.k8s.io", Version: "v1alpha1"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme ) func init() { SchemeBuilder.Register(&DNSEndpoint{}, &DNSEndpointList{}) } ================================================ FILE: apis/v1alpha1/zz_generated.deepcopy.go ================================================ //go:build !ignore_autogenerated // Code generated by controller-gen. DO NOT EDIT. package v1alpha1 import ( runtime "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/external-dns/endpoint" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNSEndpoint) DeepCopyInto(out *DNSEndpoint) { *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 DNSEndpoint. func (in *DNSEndpoint) DeepCopy() *DNSEndpoint { if in == nil { return nil } out := new(DNSEndpoint) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. func (in *DNSEndpoint) 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 *DNSEndpointList) DeepCopyInto(out *DNSEndpointList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items *out = make([]DNSEndpoint, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSEndpointList. func (in *DNSEndpointList) DeepCopy() *DNSEndpointList { if in == nil { return nil } out := new(DNSEndpointList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. func (in *DNSEndpointList) 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 *DNSEndpointSpec) DeepCopyInto(out *DNSEndpointSpec) { *out = *in if in.Endpoints != nil { in, out := &in.Endpoints, &out.Endpoints *out = make([]*endpoint.Endpoint, len(*in)) for i := range *in { if (*in)[i] != nil { in, out := &(*in)[i], &(*out)[i] *out = new(endpoint.Endpoint) (*in).DeepCopyInto(*out) } } } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSEndpointSpec. func (in *DNSEndpointSpec) DeepCopy() *DNSEndpointSpec { if in == nil { return nil } out := new(DNSEndpointSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNSEndpointStatus) DeepCopyInto(out *DNSEndpointStatus) { *out = *in } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSEndpointStatus. func (in *DNSEndpointStatus) DeepCopy() *DNSEndpointStatus { if in == nil { return nil } out := new(DNSEndpointStatus) in.DeepCopyInto(out) return out } ================================================ FILE: charts/OWNERS ================================================ labels: - chart approvers: - stevehipwell reviewers: - stevehipwell ================================================ FILE: cloudbuild.yaml ================================================ # See https://cloud.google.com/cloud-build/docs/build-config timeout: 5000s options: substitution_option: ALLOW_LOOSE machineType: 'N1_HIGHCPU_8' steps: - name: 'docker.io/library/golang:1.25-bookworm' entrypoint: make env: - VERSION=$_GIT_TAG - PULL_BASE_REF=$_PULL_BASE_REF args: - release.staging substitutions: # _GIT_TAG will be filled with a git-based tag for the image, of the form vYYYYMMDD-hash, and # can be used as a substitution _GIT_TAG: "12345" _PULL_BASE_REF: 'master' ================================================ FILE: code-of-conduct.md ================================================ # Kubernetes Community Code of Conduct Please refer to our [Kubernetes Community Code of Conduct](https://git.k8s.io/community/code-of-conduct.md) ================================================ FILE: config/crd/standard/dnsendpoints.externaldns.k8s.io.yaml ================================================ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: api-approved.kubernetes.io: https://github.com/kubernetes-sigs/external-dns/pull/2007 controller-gen.kubebuilder.io/version: v0.20.1 name: dnsendpoints.externaldns.k8s.io spec: group: externaldns.k8s.io names: kind: DNSEndpoint listKind: DNSEndpointList plural: dnsendpoints singular: dnsendpoint scope: Namespaced versions: - name: v1alpha1 schema: openAPIV3Schema: description: |- DNSEndpoint is a contract that a user-specified CRD must implement to be used as a source for external-dns. The user-specified CRD should also have the status sub-resource. 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: DNSEndpointSpec defines the desired state of DNSEndpoint properties: endpoints: items: 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: array type: object status: description: DNSEndpointStatus defines the observed state of DNSEndpoint 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: {} ================================================ FILE: controller/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - controller ================================================ FILE: controller/controller.go ================================================ /* Copyright 2017 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 controller import ( "context" "errors" "fmt" "sync" "time" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/registry" "sigs.k8s.io/external-dns/source" ) // Controller is responsible for orchestrating the different components. // It works in the following way: // * Ask the DNS provider for the current list of endpoints. // * Ask the Source for the desired list of endpoints. // * Take both lists and calculate a Plan to move current towards the desired state. // * Tell the DNS provider to apply the changes calculated by the Plan. type Controller struct { Source source.Source Registry registry.Registry // The policy that defines which change to DNS records is allowed Policy plan.Policy // The interval between individual synchronizations Interval time.Duration // The DomainFilter defines which DNS records to keep or exclude DomainFilter endpoint.DomainFilterInterface // The nextRunAt used for throttling and batching reconciliation nextRunAt time.Time // The runAtMutex is for atomic updating of nextRunAt and lastRunAt runAtMutex sync.Mutex // The lastRunAt used for throttling and batching reconciliation lastRunAt time.Time EventEmitter events.EventEmitter // MangedRecordTypes are DNS record types that will be considered for management. ManagedRecordTypes []string // ExcludeRecordTypes are DNS record types that will be excluded from management. ExcludeRecordTypes []string // MinEventSyncInterval is used as a window for batching events MinEventSyncInterval time.Duration // Old txt-owner value we need to migrate from TXTOwnerOld string } // RunOnce runs a single iteration of a reconciliation loop. func (c *Controller) RunOnce(ctx context.Context) error { lastReconcileTimestamp.Gauge.SetToCurrentTime() c.runAtMutex.Lock() c.lastRunAt = time.Now() c.runAtMutex.Unlock() regRecords, err := c.Registry.Records(ctx) if err != nil { registryErrorsTotal.Counter.Inc() deprecatedRegistryErrors.Counter.Inc() return err } registryEndpointsTotal.Gauge.Set(float64(len(regRecords))) countAddressRecords(regRecords, registryRecords) ctx = context.WithValue(ctx, provider.RecordsContextKey, regRecords) sourceEndpoints, err := c.Source.Endpoints(ctx) if err != nil { sourceErrorsTotal.Counter.Inc() deprecatedSourceErrors.Counter.Inc() return err } sourceEndpointsTotal.Gauge.Set(float64(len(sourceEndpoints))) countAddressRecords(sourceEndpoints, sourceRecords) countMatchingAddressRecords(sourceEndpoints, regRecords, verifiedRecords) endpoints, err := c.Registry.AdjustEndpoints(sourceEndpoints) if err != nil { return fmt.Errorf("adjusting endpoints: %w", err) } registryFilter := c.Registry.GetDomainFilter() plan := &plan.Plan{ Policies: []plan.Policy{c.Policy}, Current: regRecords, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{c.DomainFilter, registryFilter}, ManagedRecords: c.ManagedRecordTypes, ExcludeRecords: c.ExcludeRecordTypes, OwnerID: c.Registry.OwnerID(), OldOwnerID: c.TXTOwnerOld, } plan = plan.Calculate() if plan.Changes.HasChanges() { err = c.Registry.ApplyChanges(ctx, plan.Changes) if err != nil { registryErrorsTotal.Counter.Inc() deprecatedRegistryErrors.Counter.Inc() emitChangeEvent(c.EventEmitter, plan.Changes, events.RecordError) return err } emitChangeEvent(c.EventEmitter, plan.Changes, events.RecordReady) } else { controllerNoChangesTotal.Counter.Inc() log.Info("All records are already up to date") } lastSyncTimestamp.Gauge.SetToCurrentTime() return nil } func earliest(r time.Time, times ...time.Time) time.Time { for _, t := range times { if t.Before(r) { r = t } } return r } func latest(r time.Time, times ...time.Time) time.Time { for _, t := range times { if t.After(r) { r = t } } return r } // ScheduleRunOnce makes sure execution happens at most once per interval. func (c *Controller) ScheduleRunOnce(now time.Time) { c.runAtMutex.Lock() defer c.runAtMutex.Unlock() c.nextRunAt = latest( c.lastRunAt.Add(c.MinEventSyncInterval), earliest( now.Add(5*time.Second), c.nextRunAt, ), ) } func (c *Controller) ShouldRunOnce(now time.Time) bool { c.runAtMutex.Lock() defer c.runAtMutex.Unlock() if now.Before(c.nextRunAt) { return false } c.nextRunAt = now.Add(c.Interval) return true } // Run runs RunOnce in a loop with a delay until context is canceled func (c *Controller) Run(ctx context.Context) { ticker := time.NewTicker(time.Second) defer ticker.Stop() var softErrorCount int for { if c.ShouldRunOnce(time.Now()) { if err := c.RunOnce(ctx); err != nil { if errors.Is(err, provider.SoftError) { softErrorCount++ consecutiveSoftErrors.Gauge.Set(float64(softErrorCount)) log.Errorf("Failed to do run once: %v (consecutive soft errors: %d)", err, softErrorCount) } else { log.Fatalf("Failed to do run once: %v", err) // nolint: gocritic // exitAfterDefer } } else { if softErrorCount > 0 { log.Infof("Reconciliation succeeded after %d consecutive soft errors", softErrorCount) } softErrorCount = 0 consecutiveSoftErrors.Gauge.Set(0) } } select { case <-ticker.C: case <-ctx.Done(): log.Info("Terminating main controller loop") return } } } ================================================ FILE: controller/controller_test.go ================================================ /* Copyright 2017 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 controller import ( "context" "errors" "reflect" "sort" "sync" "testing" "time" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/pkg/events/fake" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/provider/fakes" registryfactory "sigs.k8s.io/external-dns/registry/factory" "sigs.k8s.io/external-dns/registry/noop" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) // mockProvider returns mock endpoints and validates changes. type mockProvider struct { provider.BaseProvider RecordsStore []*endpoint.Endpoint ExpectChanges *plan.Changes } type filteredMockProvider struct { provider.BaseProvider domainFilter *endpoint.DomainFilter RecordsStore []*endpoint.Endpoint RecordsCallCount int ApplyChangesCalls []*plan.Changes } func (p *filteredMockProvider) GetDomainFilter() endpoint.DomainFilterInterface { return p.domainFilter } // Records returns the desired mock endpoints. func (p *filteredMockProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { p.RecordsCallCount++ return p.RecordsStore, nil } // ApplyChanges stores all calls for later check func (p *filteredMockProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { p.ApplyChangesCalls = append(p.ApplyChangesCalls, changes) return nil } // Records returns the desired mock endpoints. func (p *mockProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { return p.RecordsStore, nil } // ApplyChanges validates that the passed in changes satisfy the assumptions. func (p *mockProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { if err := verifyEndpoints(changes.Create, p.ExpectChanges.Create); err != nil { return err } if err := verifyEndpoints(changes.UpdateNew, p.ExpectChanges.UpdateNew); err != nil { return err } if err := verifyEndpoints(changes.UpdateOld, p.ExpectChanges.UpdateOld); err != nil { return err } if err := verifyEndpoints(changes.Delete, p.ExpectChanges.Delete); err != nil { return err } if !reflect.DeepEqual(ctx.Value(provider.RecordsContextKey), p.RecordsStore) { return errors.New("context is wrong") } return nil } func verifyEndpoints(actual, expected []*endpoint.Endpoint) error { if len(actual) != len(expected) { return errors.New("number of records is wrong") } sort.Slice(actual, func(i, j int) bool { return actual[i].DNSName < actual[j].DNSName }) for i := range actual { if actual[i].DNSName != expected[i].DNSName || !actual[i].Targets.Same(expected[i].Targets) { return errors.New("record is wrong") } } return nil } // newMockProvider creates a new mockProvider returning the given endpoints and validating the desired changes. func newMockProvider(endpoints []*endpoint.Endpoint, changes *plan.Changes) provider.Provider { dnsProvider := &mockProvider{ RecordsStore: endpoints, ExpectChanges: changes, } return dnsProvider } func getTestSource() *testutils.MockSource { // Fake some desired endpoints coming from our source. source := new(testutils.MockSource) source.On("Endpoints").Return([]*endpoint.Endpoint{ { DNSName: "create-record", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "update-record", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.4.4"}, }, { DNSName: "create-aaaa-record", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, { DNSName: "update-aaaa-record", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::2"}, }, }, nil) return source } func getTestConfig() *externaldns.Config { cfg := externaldns.NewConfig() cfg.Registry = externaldns.RegistryNoop cfg.ManagedDNSRecordTypes = []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME} return cfg } func getTestProvider() provider.Provider { // Fake some existing records in our DNS provider and validate some desired changes. return newMockProvider( []*endpoint.Endpoint{ { DNSName: "update-record", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "delete-record", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"4.3.2.1"}, }, { DNSName: "update-aaaa-record", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::3"}, }, { DNSName: "delete-aaaa-record", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::4"}, }, }, &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "create-aaaa-record", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}}, {DNSName: "create-record", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, UpdateNew: []*endpoint.Endpoint{ {DNSName: "update-aaaa-record", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::2"}}, {DNSName: "update-record", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.4.4"}}, }, UpdateOld: []*endpoint.Endpoint{ {DNSName: "update-aaaa-record", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::3"}}, {DNSName: "update-record", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}}, }, Delete: []*endpoint.Endpoint{ {DNSName: "delete-aaaa-record", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::4"}}, {DNSName: "delete-record", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"4.3.2.1"}}, }, }, ) } // TestRunOnce tests that RunOnce correctly orchestrates the different components. func TestRunOnce(t *testing.T) { source := getTestSource() cfg := getTestConfig() provider := getTestProvider() emitter := fake.NewFakeEventEmitter() r, err := registryfactory.Select(cfg, provider) require.NoError(t, err) // Run our controller once to trigger the validation. ctrl := &Controller{ Source: source, Registry: r, Policy: &plan.SyncPolicy{}, ManagedRecordTypes: cfg.ManagedDNSRecordTypes, EventEmitter: emitter, } assert.NoError(t, ctrl.RunOnce(t.Context())) // Validate that the mock source was called. source.AssertExpectations(t) // check the verified records testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) emitter.AssertNumberOfCalls(t, "Add", 6) } // TestRun tests that Run correctly starts and stops func TestRun(t *testing.T) { source := getTestSource() cfg := getTestConfig() provider := getTestProvider() r, err := registryfactory.Select(cfg, provider) require.NoError(t, err) // Run our controller once to trigger the validation. ctrl := &Controller{ Source: source, Registry: r, Policy: &plan.SyncPolicy{}, ManagedRecordTypes: cfg.ManagedDNSRecordTypes, } ctrl.nextRunAt = time.Now().Add(-time.Millisecond) ctx, cancel := context.WithCancel(t.Context()) stopped := make(chan struct{}) go func() { ctrl.Run(ctx) close(stopped) }() time.Sleep(1500 * time.Millisecond) cancel() // start shutdown <-stopped // Validate that the mock source was called. source.AssertExpectations(t) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) } func TestShouldRunOnce(t *testing.T) { ctrl := &Controller{Interval: 10 * time.Minute, MinEventSyncInterval: 15 * time.Second} now := time.Now() // First run of Run loop should execute RunOnce assert.True(t, ctrl.ShouldRunOnce(now)) assert.Equal(t, now.Add(10*time.Minute), ctrl.nextRunAt) // Second run should not assert.False(t, ctrl.ShouldRunOnce(now)) ctrl.lastRunAt = now now = now.Add(10 * time.Second) // Changes happen in ingresses or services ctrl.ScheduleRunOnce(now) ctrl.ScheduleRunOnce(now) // Because we batch changes, ShouldRunOnce returns False at first assert.False(t, ctrl.ShouldRunOnce(now)) assert.False(t, ctrl.ShouldRunOnce(now.Add(100*time.Microsecond))) // But after MinInterval we should run reconciliation now = now.Add(5 * time.Second) assert.True(t, ctrl.ShouldRunOnce(now)) // But just one time assert.False(t, ctrl.ShouldRunOnce(now)) // We should wait maximum possible time after last reconciliation started now = now.Add(10*time.Minute - time.Second) assert.False(t, ctrl.ShouldRunOnce(now)) // After exactly Interval it's OK again to reconcile now = now.Add(time.Second) assert.True(t, ctrl.ShouldRunOnce(now)) // But not two times assert.False(t, ctrl.ShouldRunOnce(now)) // Multiple ingresses or services changes, closer than MinInterval from each other ctrl.lastRunAt = now firstChangeTime := now secondChangeTime := firstChangeTime.Add(time.Second) // First change ctrl.ScheduleRunOnce(firstChangeTime) // Second change ctrl.ScheduleRunOnce(secondChangeTime) // Executions should be spaced by at least MinEventSyncInterval assert.False(t, ctrl.ShouldRunOnce(now.Add(5*time.Second))) // Should not postpone the reconciliation further than firstChangeTime + MinInterval now = now.Add(ctrl.MinEventSyncInterval) assert.True(t, ctrl.ShouldRunOnce(now)) } func testControllerFiltersDomains(t *testing.T, configuredEndpoints []*endpoint.Endpoint, domainFilter *endpoint.DomainFilter, providerEndpoints []*endpoint.Endpoint, expectedChanges []*plan.Changes) { t.Helper() cfg := externaldns.NewConfig() cfg.Registry = externaldns.RegistryNoop cfg.ManagedDNSRecordTypes = []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME} source := new(testutils.MockSource) source.On("Endpoints").Return(configuredEndpoints, nil) // Fake some existing records in our DNS provider and validate some desired changes. provider := &filteredMockProvider{ RecordsStore: providerEndpoints, } r, err := registryfactory.Select(cfg, provider) require.NoError(t, err) ctrl := &Controller{ Source: source, Registry: r, Policy: &plan.SyncPolicy{}, DomainFilter: domainFilter, ManagedRecordTypes: cfg.ManagedDNSRecordTypes, } assert.NoError(t, ctrl.RunOnce(t.Context())) assert.Equal(t, 1, provider.RecordsCallCount) require.Len(t, provider.ApplyChangesCalls, len(expectedChanges)) for i, change := range expectedChanges { assert.Equal(t, *change, *provider.ApplyChangesCalls[i]) } } func TestControllerSkipsEmptyChanges(t *testing.T) { testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "create-record.other.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, endpoint.NewDomainFilter([]string{"used.tld"}), []*endpoint.Endpoint{ { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, []*plan.Changes{}, ) } func TestWhenNoFilterControllerConsidersAllDomains(t *testing.T) { testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "create-record.other.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, &endpoint.DomainFilter{}, []*endpoint.Endpoint{ { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, []*plan.Changes{ { Create: []*endpoint.Endpoint{ { DNSName: "create-record.other.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, }, }, }, ) } func TestWhenMultipleControllerConsidersAllFilteredComain(t *testing.T) { testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "create-record.other.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, }, { DNSName: "create-record.unused.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, }, endpoint.NewDomainFilter([]string{"used.tld", "other.tld"}), []*endpoint.Endpoint{ { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, []*plan.Changes{ { Create: []*endpoint.Endpoint{ { DNSName: "create-record.other.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, }, UpdateOld: []*endpoint.Endpoint{ { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, Labels: endpoint.Labels{}, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, Labels: endpoint.Labels{ "owner": "", }, }, }, }, }, ) } type toggleRegistry struct { noop.NoopRegistry failCount int failCountMu sync.Mutex // protects failCount } const toggleRegistryFailureCount = 3 func (r *toggleRegistry) Records(_ context.Context) ([]*endpoint.Endpoint, error) { r.failCountMu.Lock() defer r.failCountMu.Unlock() if r.failCount < toggleRegistryFailureCount { r.failCount++ return nil, provider.SoftError } return []*endpoint.Endpoint{}, nil } func (r *toggleRegistry) ApplyChanges(_ context.Context, _ *plan.Changes) error { return nil } func TestToggleRegistry(t *testing.T) { source := getTestSource() cfg := getTestConfig() r := &toggleRegistry{} interval := 10 * time.Millisecond ctrl := &Controller{ Source: source, Registry: r, Policy: &plan.SyncPolicy{}, ManagedRecordTypes: cfg.ManagedDNSRecordTypes, Interval: interval, } ctrl.nextRunAt = time.Now().Add(-time.Millisecond) ctx, cancel := context.WithCancel(t.Context()) stopped := make(chan struct{}) go func() { ctrl.Run(ctx) close(stopped) }() // Wait up to 1 minute for failCount to reach at least 3 // The timeout serves as a safety net against infinite loops while being // sufficiently large to accommodate slow CI environments deadline := time.Now().Add(15 * time.Second) for { r.failCountMu.Lock() count := r.failCount r.failCountMu.Unlock() if count >= toggleRegistryFailureCount { break } if time.Now().After(deadline) { break } // Sleep for the controller interval to avoid busy waiting // since the controller won't run again until the interval passes time.Sleep(interval) } cancel() <-stopped r.failCountMu.Lock() finalCount := r.failCount r.failCountMu.Unlock() assert.Equal(t, toggleRegistryFailureCount, finalCount, "failCount should be at least %d", toggleRegistryFailureCount) } func TestRunOnce_EmitChangeEvent(t *testing.T) { tests := []struct { name string applyErr error expectedReason events.Reason expectErr bool }{ { name: "emits RecordReady on success", expectedReason: events.RecordReady, }, { name: "emits RecordError on failure", applyErr: errors.New("apply failed"), expectedReason: events.RecordError, expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { source := new(testutils.MockSource) source.On("Endpoints").Return([]*endpoint.Endpoint{ endpoint.NewEndpoint("dot.com", endpoint.RecordTypeA, "1.2.3.4"). WithRefObject(&events.ObjectReference{}), }, nil) r, err := registryfactory.Select(getTestConfig(), &fakes.MockProvider{ApplyChangesErr: tt.applyErr}) require.NoError(t, err) emitter := fake.NewFakeEventEmitter() ctrl := &Controller{ Source: source, Registry: r, Policy: &plan.SyncPolicy{}, ManagedRecordTypes: []string{endpoint.RecordTypeA}, EventEmitter: emitter, } err = ctrl.RunOnce(t.Context()) assert.Equal(t, tt.expectErr, err != nil) emitter.AssertCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool { return e.Reason() == tt.expectedReason })) }) } } ================================================ FILE: controller/events.go ================================================ /* 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 controller import ( "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/plan" ) // emitChangeEvent emits a Kubernetes event for each DNS record change. // Deletes use RecordDeleted on success and RecordError on failure. func emitChangeEvent(e events.EventEmitter, ch *plan.Changes, reason events.Reason) { if e == nil { return } for _, ep := range ch.Create { e.Add(events.NewEventFromEndpoint(ep, events.ActionCreate, reason)) } for _, ep := range ch.UpdateNew { e.Add(events.NewEventFromEndpoint(ep, events.ActionUpdate, reason)) } deleteReason := events.RecordDeleted if reason == events.RecordError { deleteReason = events.RecordError } for _, ep := range ch.Delete { e.Add(events.NewEventFromEndpoint(ep, events.ActionDelete, deleteReason)) } } ================================================ FILE: controller/events_test.go ================================================ /* 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 controller import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/pkg/events/fake" "sigs.k8s.io/external-dns/plan" ) func TestEmit_RecordReady(t *testing.T) { refObj := &events.ObjectReference{} tests := []struct { name string changes plan.Changes asserts func(em *fake.EventEmitter, ch plan.Changes) }{ { name: "create, update and delete endpoints", changes: plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("one.example.com", endpoint.RecordTypeA, "10.10.10.0").WithRefObject(refObj), endpoint.NewEndpoint("two.example.com", endpoint.RecordTypeA, "10.10.10.1").WithRefObject(refObj), }, UpdateNew: []*endpoint.Endpoint{ endpoint.NewEndpoint("three.example.com", endpoint.RecordTypeA, "10.10.10.2").WithRefObject(refObj), endpoint.NewEndpoint("four.example.com", endpoint.RecordTypeA, "10.10.10.3").WithRefObject(refObj), }, Delete: []*endpoint.Endpoint{ endpoint.NewEndpoint("five.example.com", endpoint.RecordTypeA, "192.10.10.0").WithRefObject(refObj), }, }, asserts: func(em *fake.EventEmitter, ch plan.Changes) { for _, ep := range ch.Create { em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ep, events.ActionCreate, events.RecordReady)) } for _, ep := range ch.Delete { em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ep, events.ActionDelete, events.RecordDeleted)) } em.AssertNotCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool { return e.EventType() == events.EventTypeWarning })) em.AssertNumberOfCalls(t, "Add", 5) }, }, { name: "delete endpoints", changes: plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{ endpoint.NewEndpoint("five.example.com", endpoint.RecordTypeA, "192.10.10.0").WithRefObject(refObj), }, }, asserts: func(em *fake.EventEmitter, ch plan.Changes) { for _, ep := range ch.Delete { em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ep, events.ActionDelete, events.RecordDeleted)) } em.AssertCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool { return e.EventType() == events.EventTypeNormal && e.Action() == events.ActionDelete && e.Reason() == events.RecordDeleted })) em.AssertNumberOfCalls(t, "Add", 1) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { emitter := fake.NewFakeEventEmitter() emitChangeEvent(emitter, &tt.changes, events.RecordReady) tt.asserts(emitter, tt.changes) mock.AssertExpectationsForObjects(t, emitter) }) } } func TestEmit_NilEmitter(t *testing.T) { assert.NotPanics(t, func() { emitChangeEvent(nil, &plan.Changes{}, events.RecordError) }) } func TestEmit_RecordError(t *testing.T) { refObj := &events.ObjectReference{} tests := []struct { name string changes plan.Changes asserts func(em *fake.EventEmitter, ch plan.Changes) }{ { name: "create, update and delete endpoints", changes: plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("one.example.com", endpoint.RecordTypeA, "10.10.10.0").WithRefObject(refObj), }, UpdateNew: []*endpoint.Endpoint{ endpoint.NewEndpoint("two.example.com", endpoint.RecordTypeA, "10.10.10.1").WithRefObject(refObj), }, Delete: []*endpoint.Endpoint{ endpoint.NewEndpoint("three.example.com", endpoint.RecordTypeA, "10.10.10.2").WithRefObject(refObj), }, }, asserts: func(em *fake.EventEmitter, ch plan.Changes) { em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ch.Create[0], events.ActionCreate, events.RecordError)) em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ch.UpdateNew[0], events.ActionUpdate, events.RecordError)) em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ch.Delete[0], events.ActionDelete, events.RecordError)) em.AssertNumberOfCalls(t, "Add", 3) }, }, { name: "delete endpoints emit RecordError not RecordDeleted", changes: plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{ endpoint.NewEndpoint("five.example.com", endpoint.RecordTypeA, "192.10.10.0").WithRefObject(refObj), }, }, asserts: func(em *fake.EventEmitter, ch plan.Changes) { em.AssertCalled(t, "Add", events.NewEventFromEndpoint(ch.Delete[0], events.ActionDelete, events.RecordError)) em.AssertNotCalled(t, "Add", mock.MatchedBy(func(e events.Event) bool { return e.Reason() == events.RecordDeleted })) em.AssertNumberOfCalls(t, "Add", 1) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { emitter := fake.NewFakeEventEmitter() emitChangeEvent(emitter, &tt.changes, events.RecordError) tt.asserts(emitter, tt.changes) mock.AssertExpectationsForObjects(t, emitter) }) } } ================================================ FILE: controller/execute.go ================================================ /* 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 controller import ( "context" "fmt" "net/http" "os" "os/signal" "syscall" "time" "github.com/go-logr/logr" "github.com/prometheus/client_golang/prometheus/promhttp" log "github.com/sirupsen/logrus" "k8s.io/klog/v2" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/pkg/apis/externaldns/validation" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/pkg/metrics" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" providerfactory "sigs.k8s.io/external-dns/provider/factory" webhookapi "sigs.k8s.io/external-dns/provider/webhook/api" registryfactory "sigs.k8s.io/external-dns/registry/factory" "sigs.k8s.io/external-dns/source" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/wrappers" ) func Execute() { cfg := externaldns.NewConfig() if err := cfg.ParseFlags(os.Args[1:]); err != nil { log.Fatalf("flag parsing error: %v", err) } log.Infof("config: %s", cfg) if err := validation.ValidateConfig(cfg); err != nil { log.Fatalf("config validation failed: %v", err) } // Set annotation prefix (required since init() was removed) annotations.SetAnnotationPrefix(cfg.AnnotationPrefix) if cfg.AnnotationPrefix != annotations.DefaultAnnotationPrefix { log.Infof("Using custom annotation prefix: %s", cfg.AnnotationPrefix) } configureLogger(cfg) if cfg.DryRun { log.Info("running in dry-run mode. No changes to DNS records will be made.") } if log.GetLevel() < log.DebugLevel { // Klog V2 is used by k8s.io/apimachinery/pkg/labels and can throw (a lot) of irrelevant logs // See https://github.com/kubernetes-sigs/external-dns/issues/2348 defer klog.ClearLogger() klog.SetLogger(logr.Discard()) } log.Info(externaldns.Banner()) ctx, cancel := context.WithCancel(context.Background()) go serveMetrics(cfg.MetricsAddress) go handleSigterm(cancel) sCfg := source.NewSourceConfig(cfg) endpointsSource, err := buildSource(ctx, sCfg) if err != nil { log.Fatal(err) // nolint: gocritic // exitAfterDefer } domainFilter := endpoint.NewDomainFilterWithOptions( endpoint.WithDomainFilter(cfg.DomainFilter), endpoint.WithDomainExclude(cfg.DomainExclude), endpoint.WithRegexDomainFilter(cfg.RegexDomainFilter), endpoint.WithRegexDomainExclude(cfg.RegexDomainExclude), ) prvdr, err := providerfactory.Select(ctx, cfg, domainFilter) if err != nil { log.Fatal(err) } if cfg.WebhookServer { webhookapi.StartHTTPApi(prvdr, nil, cfg.WebhookProviderReadTimeout, cfg.WebhookProviderWriteTimeout, "127.0.0.1:8888") os.Exit(0) } ctrl, err := buildController(ctx, cfg, sCfg, endpointsSource, prvdr, domainFilter) if err != nil { log.Fatal(err) } if cfg.Once { err := ctrl.RunOnce(ctx) if err != nil { log.Fatal(err) } os.Exit(0) } if cfg.UpdateEvents { // Add RunOnce as the handler function that will be called when ingress/service sources have changed. // Note that k8s Informers will perform an initial list operation, which results in the handler // function initially being called for every Service/Ingress that exists ctrl.Source.AddEventHandler(ctx, func() { ctrl.ScheduleRunOnce(time.Now()) }) } ctrl.ScheduleRunOnce(time.Now()) ctrl.Run(ctx) } func buildController( ctx context.Context, cfg *externaldns.Config, sCfg *source.Config, src source.Source, p provider.Provider, filter *endpoint.DomainFilter, ) (*Controller, error) { policy, ok := plan.Policies[cfg.Policy] if !ok { return nil, fmt.Errorf("unknown policy: %s", cfg.Policy) } reg, err := registryfactory.Select(cfg, p) if err != nil { return nil, err } eventsCfg := events.NewConfig( events.WithEmitEvents(cfg.EmitEvents), events.WithDryRun(cfg.DryRun)) var eventEmitter events.EventEmitter if eventsCfg.IsEnabled() { kubeClient, err := sCfg.ClientGenerator().KubeClient() if err != nil { return nil, err } eventCtrl, err := events.NewEventController(kubeClient.EventsV1(), eventsCfg) if err != nil { return nil, err } eventCtrl.Run(ctx) eventEmitter = eventCtrl } return &Controller{ Source: src, Registry: reg, Policy: policy, Interval: cfg.Interval, DomainFilter: filter, ManagedRecordTypes: cfg.ManagedDNSRecordTypes, ExcludeRecordTypes: cfg.ExcludeDNSRecordTypes, MinEventSyncInterval: cfg.MinEventSyncInterval, TXTOwnerOld: cfg.TXTOwnerOld, EventEmitter: eventEmitter, }, nil } // This function configures the logger format and level based on the provided configuration. func configureLogger(cfg *externaldns.Config) { if cfg.LogFormat == "json" { log.SetFormatter(&log.JSONFormatter{}) } ll, err := log.ParseLevel(cfg.LogLevel) if err != nil { log.Fatalf("failed to parse log level: %v", err) } log.SetLevel(ll) } // buildSource creates and configures the source(s) for endpoint discovery based on the provided configuration. // It initializes the source configuration, generates the required sources, and combines them into a single, // deduplicated source. Returns the combined source or an error if source creation fails. func buildSource(ctx context.Context, cfg *source.Config) (source.Source, error) { sources, err := source.ByNames(ctx, cfg, cfg.ClientGenerator()) if err != nil { return nil, err } opts := wrappers.NewConfig( wrappers.WithDefaultTargets(cfg.DefaultTargets), wrappers.WithForceDefaultTargets(cfg.ForceDefaultTargets), wrappers.WithNAT64Networks(cfg.NAT64Networks), wrappers.WithTargetNetFilter(cfg.TargetNetFilter), wrappers.WithExcludeTargetNets(cfg.ExcludeTargetNets), wrappers.WithMinTTL(cfg.MinTTL), wrappers.WithProvider(cfg.Provider), wrappers.WithPreferAlias(cfg.PreferAlias)) return wrappers.WrapSources(sources, opts) } // handleSigterm listens for a SIGTERM signal and triggers the provided cancel function // to gracefully terminate the application. It logs a message when the signal is received. func handleSigterm(cancel func()) { signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGTERM) <-signals log.Info("Received SIGTERM. Terminating...") cancel() } // serveMetrics starts an HTTP server that serves health and metrics endpoints. // The /healthz endpoint returns a 200 OK status to indicate the service is healthy. // The /metrics endpoint serves Prometheus metrics. // The server listens on the specified address and logs debug information about the endpoints. func serveMetrics(address string) { http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) }) log.Debugf("serving 'healthz' on '%s/healthz'", address) log.Debugf("serving 'metrics' on '%s/metrics'", address) log.Debugf("registered '%d' metrics", len(metrics.RegisterMetric.Metrics)) http.Handle("/metrics", promhttp.Handler()) log.Fatal(http.ListenAndServe(address, nil)) } ================================================ FILE: controller/execute_test.go ================================================ /* 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 controller import ( "bytes" "context" "errors" "net/http" "net/http/httptest" "os" "os/exec" "testing" "time" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" provider "sigs.k8s.io/external-dns/provider/factory" "sigs.k8s.io/external-dns/source" ) // Logger func TestConfigureLogger(t *testing.T) { tests := []struct { name string cfg *externaldns.Config wantLevel log.Level wantJSON bool wantErr bool wantErrMsg string }{ { name: "Default log format and level", cfg: &externaldns.Config{ LogLevel: "info", LogFormat: "text", }, wantLevel: log.InfoLevel, }, { name: "JSON log format", cfg: &externaldns.Config{ LogLevel: "debug", LogFormat: "json", }, wantLevel: log.DebugLevel, wantJSON: true, }, { name: "Invalid log level", cfg: &externaldns.Config{ LogLevel: "invalid", LogFormat: "text", }, wantLevel: log.InfoLevel, wantErr: true, wantErrMsg: "failed to parse log level", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantErr { // Capture and suppress fatal exit; restore logger after test logger := log.StandardLogger() prevOut := logger.Out prevExit := logger.ExitFunc b := new(bytes.Buffer) var captureLogFatal bool logger.ExitFunc = func(int) { captureLogFatal = true } logger.SetOutput(b) t.Cleanup(func() { logger.SetOutput(prevOut) logger.ExitFunc = prevExit }) configureLogger(tt.cfg) assert.True(t, captureLogFatal) assert.Contains(t, b.String(), tt.wantErrMsg) } else { // Save and restore logger state to avoid leaking between tests logger := log.StandardLogger() prevFormatter := logger.Formatter prevLevel := log.GetLevel() t.Cleanup(func() { log.SetLevel(prevLevel) logger.SetFormatter(prevFormatter) }) configureLogger(tt.cfg) assert.Equal(t, tt.wantLevel, log.GetLevel()) if tt.wantJSON { assert.IsType(t, &log.JSONFormatter{}, log.StandardLogger().Formatter) } else { assert.IsType(t, &log.TextFormatter{}, log.StandardLogger().Formatter) } } }) } } func TestBuildSourceWithWrappers(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotImplemented) })) defer svr.Close() tests := []struct { name string cfg *externaldns.Config asserts func(*testing.T, *externaldns.Config) }{ { name: "configuration with target filter wrapper", cfg: &externaldns.Config{ APIServerURL: svr.URL, Sources: []string{"fake"}, TargetNetFilter: []string{"10.0.0.0/8"}, }, }, { name: "configuration with nat64 networks", cfg: &externaldns.Config{ APIServerURL: svr.URL, Sources: []string{"fake"}, NAT64Networks: []string{"2001:db8::/96"}, }, }, { name: "default configuration", cfg: &externaldns.Config{ APIServerURL: svr.URL, Sources: []string{"fake"}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := buildSource(t.Context(), source.NewSourceConfig(tt.cfg)) require.NoError(t, err) }) } } // Helper used by runExecuteSubprocess. func TestHelperProcess(_ *testing.T) { if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { return } // Parse args after the "--" sentinel. idx := -1 for i, a := range os.Args { if a == "--" { idx = i break } } var args []string if idx >= 0 { args = os.Args[idx+1:] } os.Args = append([]string{"external-dns"}, args...) Execute() } // runExecuteSubprocess runs Execute in a separate process and returns exit code. func runExecuteSubprocess(t *testing.T, args []string) (int, error) { t.Helper() // make sure the subprocess does not run forever ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() // TODO: investigate why -test.run=TestHelperProcess cmdArgs := append([]string{"-test.run=TestHelperProcess", "--"}, args...) cmd := exec.CommandContext(ctx, os.Args[0], cmdArgs...) cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf err := cmd.Run() if errors.Is(ctx.Err(), context.DeadlineExceeded) { return -1, ctx.Err() } if err == nil { return 0, nil } ee := &exec.ExitError{} if errors.As(err, &ee) { return ee.ExitCode(), nil } return -1, err } func TestExecuteOnceDryRunExitsZero(t *testing.T) { // Use :0 for an ephemeral metrics port. code, err := runExecuteSubprocess(t, []string{ "--source", "fake", "--provider", "inmemory", "--once", "--dry-run", "--metrics-address", ":0", }) require.NoError(t, err) assert.Equal(t, 0, code) } func TestExecuteUnknownProviderExitsNonZero(t *testing.T) { code, err := runExecuteSubprocess(t, []string{ "--source", "fake", "--provider", "unknown", "--metrics-address", ":0", }) require.NoError(t, err) assert.NotEqual(t, 0, code) } func TestExecuteValidationErrorNoSources(t *testing.T) { code, err := runExecuteSubprocess(t, []string{ "--provider", "inmemory", "--metrics-address", ":0", }) require.NoError(t, err) assert.NotEqual(t, 0, code) } func TestExecuteFlagParsingErrorInvalidLogFormat(t *testing.T) { code, err := runExecuteSubprocess(t, []string{ "--log-format", "invalid", // Provide minimal required flags to keep errors focused on parsing "--source", "fake", "--provider", "inmemory", "--metrics-address", ":0", }) require.NoError(t, err) assert.NotEqual(t, 0, code) } // Config validation failure triggers log.Fatalf. func TestExecuteConfigValidationErrorExitsNonZero(t *testing.T) { code, err := runExecuteSubprocess(t, []string{ "--source", "fake", // Choose a provider with validation that fails without required flags "--provider", "azure", // No --azure-config-file provided "--metrics-address", ":0", }) require.NoError(t, err) assert.NotEqual(t, 0, code) } // buildSource failure triggers log.Fatal. func TestExecuteBuildSourceErrorExitsNonZero(t *testing.T) { // Use a valid source name (ingress) and an invalid kubeconfig path to // force client creation failure inside buildSource. code, err := runExecuteSubprocess(t, []string{ "--source", "ingress", "--kubeconfig", "this/path/does/not/exist", "--provider", "inmemory", "--metrics-address", ":0", }) require.NoError(t, err) assert.NotEqual(t, 0, code) } // RunOnce error exits non-zero. func TestExecuteRunOnceErrorExitsNonZero(t *testing.T) { // Connector source dials a TCP server; use a closed port to fail. code, err := runExecuteSubprocess(t, []string{ "--source", "connector", "--connector-source-server", "127.0.0.1:1", "--provider", "inmemory", "--once", "--metrics-address", ":0", }) require.NoError(t, err) assert.NotEqual(t, 0, code) } // Run loop error exits non-zero. func TestExecuteRunLoopErrorExitsNonZero(t *testing.T) { code, err := runExecuteSubprocess(t, []string{ "--source", "connector", "--connector-source-server", "127.0.0.1:1", "--provider", "inmemory", "--metrics-address", ":0", }) require.NoError(t, err) assert.NotEqual(t, 0, code) } // buildController registry-creation failure triggers log.Fatal. func TestExecuteBuildControllerErrorExitsNonZero(t *testing.T) { code, err := runExecuteSubprocess(t, []string{ "--source", "fake", "--provider", "inmemory", "--registry", "dynamodb", // Force NewDynamoDBRegistry to fail validation by using empty owner id "--txt-owner-id", "", "--metrics-address", ":0", }) require.NoError(t, err) assert.NotEqual(t, 0, code) } // Controller run loop stops on context cancel. func TestControllerRunCancelContextStopsLoop(t *testing.T) { // Minimal controller using fake source and inmemory provider. cfg := &externaldns.Config{ Sources: []string{"fake"}, Provider: "inmemory", LogLevel: "error", LogFormat: "text", Policy: "sync", Registry: "txt", TXTOwnerID: "test-owner", } sCfg := source.NewSourceConfig(cfg) ctx, cancel := context.WithCancel(t.Context()) defer cancel() src, err := buildSource(ctx, sCfg) require.NoError(t, err) domainFilter := endpoint.NewDomainFilterWithOptions( endpoint.WithDomainFilter(cfg.DomainFilter), endpoint.WithDomainExclude(cfg.DomainExclude), endpoint.WithRegexDomainFilter(cfg.RegexDomainFilter), endpoint.WithRegexDomainExclude(cfg.RegexDomainExclude), ) p, err := provider.Select(ctx, cfg, domainFilter) require.NoError(t, err) ctrl, err := buildController(ctx, cfg, sCfg, src, p, domainFilter) require.NoError(t, err) done := make(chan struct{}) go func() { ctrl.Run(ctx) close(done) }() cancel() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("controller did not stop after context cancellation") } } ================================================ FILE: controller/metrics.go ================================================ /* 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 controller import ( "github.com/prometheus/client_golang/prometheus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/metrics" ) var ( registryErrorsTotal = metrics.NewCounterWithOpts( prometheus.CounterOpts{ Subsystem: "registry", Name: "errors_total", Help: "Number of Registry errors.", }, ) sourceErrorsTotal = metrics.NewCounterWithOpts( prometheus.CounterOpts{ Subsystem: "source", Name: "errors_total", Help: "Number of Source errors.", }, ) sourceEndpointsTotal = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "source", Name: "endpoints_total", Help: "Number of Endpoints in all sources", }, ) registryEndpointsTotal = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "registry", Name: "endpoints_total", Help: "Number of Endpoints in the registry", }, ) lastSyncTimestamp = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "controller", Name: "last_sync_timestamp_seconds", Help: "Timestamp of last successful sync with the DNS provider", }, ) lastReconcileTimestamp = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "controller", Name: "last_reconcile_timestamp_seconds", Help: "Timestamp of last attempted sync with the DNS provider", }, ) controllerNoChangesTotal = metrics.NewCounterWithOpts( prometheus.CounterOpts{ Subsystem: "controller", Name: "no_op_runs_total", Help: "Number of reconcile loops ending up with no changes on the DNS provider side.", }, ) deprecatedRegistryErrors = metrics.NewCounterWithOpts( prometheus.CounterOpts{ Subsystem: "registry", Name: "errors_total", Help: "Number of Registry errors.", }, ) deprecatedSourceErrors = metrics.NewCounterWithOpts( prometheus.CounterOpts{ Subsystem: "source", Name: "errors_total", Help: "Number of Source errors.", }, ) registryRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ Subsystem: "registry", Name: "records", Help: "Number of registry records partitioned by label name (vector).", }, []string{"record_type"}, ) sourceRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ Subsystem: "source", Name: "records", Help: "Number of source records partitioned by label name (vector).", }, []string{"record_type"}, ) verifiedRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ Subsystem: "controller", Name: "verified_records", Help: "Number of DNS records that exists both in source and registry (vector).", }, []string{"record_type"}, ) consecutiveSoftErrors = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "controller", Name: "consecutive_soft_errors", Help: "Number of consecutive soft errors in reconciliation loop.", }, ) ) func init() { metrics.RegisterMetric.MustRegister(registryErrorsTotal) metrics.RegisterMetric.MustRegister(sourceErrorsTotal) metrics.RegisterMetric.MustRegister(sourceEndpointsTotal) metrics.RegisterMetric.MustRegister(registryEndpointsTotal) metrics.RegisterMetric.MustRegister(lastSyncTimestamp) metrics.RegisterMetric.MustRegister(lastReconcileTimestamp) metrics.RegisterMetric.MustRegister(deprecatedRegistryErrors) metrics.RegisterMetric.MustRegister(deprecatedSourceErrors) metrics.RegisterMetric.MustRegister(controllerNoChangesTotal) metrics.RegisterMetric.MustRegister(registryRecords) metrics.RegisterMetric.MustRegister(sourceRecords) metrics.RegisterMetric.MustRegister(verifiedRecords) metrics.RegisterMetric.MustRegister(consecutiveSoftErrors) } type dnsKey struct { name string recordType string } // countMatchingAddressRecords counts records that exist in both endpoints and registry. func countMatchingAddressRecords(endpoints []*endpoint.Endpoint, registryRecords []*endpoint.Endpoint, metric metrics.GaugeVecMetric) { metric.Gauge.Reset() registry := make(map[dnsKey]struct{}, len(registryRecords)) for _, r := range registryRecords { registry[dnsKey{r.DNSName, r.RecordType}] = struct{}{} } counts := make(map[string]float64) for _, ep := range endpoints { if _, found := registry[dnsKey{ep.DNSName, ep.RecordType}]; found { counts[ep.RecordType]++ } } for recordType, count := range counts { metric.AddWithLabels(count, recordType) } } // countAddressRecords counts each record type in the provided endpoints slice. func countAddressRecords(endpoints []*endpoint.Endpoint, metric metrics.GaugeVecMetric) { metric.Gauge.Reset() counts := make(map[string]float64) for _, ep := range endpoints { counts[ep.RecordType]++ } for recordType, count := range counts { metric.AddWithLabels(count, recordType) } } ================================================ FILE: controller/metrics_test.go ================================================ /* 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 controller import ( "testing" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" registryfactory "sigs.k8s.io/external-dns/registry/factory" ) func TestVerifyARecords(t *testing.T) { testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "create-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, endpoint.NewDomainFilter([]string{"used.tld"}), []*endpoint.Endpoint{ { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "create-record.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, }, []*plan.Changes{}, ) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "some-record.1.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "some-record.2.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "some-record.3.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"24.24.24.24"}, }, }, endpoint.NewDomainFilter([]string{"used.tld"}), []*endpoint.Endpoint{ { DNSName: "some-record.1.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "some-record.2.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, []*plan.Changes{{ Create: []*endpoint.Endpoint{ { DNSName: "some-record.3.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"24.24.24.24"}, }, }, }}, ) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) } func TestVerifyAAAARecords(t *testing.T) { testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "create-record.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::2"}, }, }, endpoint.NewDomainFilter([]string{"used.tld"}), []*endpoint.Endpoint{ { DNSName: "some-record.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::2"}, }, { DNSName: "create-record.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, }, []*plan.Changes{}, ) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "some-record.1.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, { DNSName: "some-record.2.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::2"}, }, { DNSName: "some-record.3.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::3"}, }, }, endpoint.NewDomainFilter([]string{"used.tld"}), []*endpoint.Endpoint{ { DNSName: "some-record.1.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, { DNSName: "some-record.2.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::2"}, }, }, []*plan.Changes{{ Create: []*endpoint.Endpoint{ { DNSName: "some-record.3.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::3"}, }, }, }}, ) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) } func TestARecords(t *testing.T) { testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "record1.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "record2.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "_mysql-svc._tcp.mysql.used.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, }, }, endpoint.NewDomainFilter([]string{"used.tld"}), []*endpoint.Endpoint{ { DNSName: "record1.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "_mysql-svc._tcp.mysql.used.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, }, }, []*plan.Changes{{ Create: []*endpoint.Endpoint{ { DNSName: "record2.used.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }}, ) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) } func TestAAAARecords(t *testing.T) { testControllerFiltersDomains( t, []*endpoint.Endpoint{ { DNSName: "record1.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, { DNSName: "record2.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::2"}, }, { DNSName: "_mysql-svc._tcp.mysql.used.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, }, }, endpoint.NewDomainFilter([]string{"used.tld"}), []*endpoint.Endpoint{ { DNSName: "record1.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, { DNSName: "_mysql-svc._tcp.mysql.used.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 30007 mysql.used.tld"}, }, }, []*plan.Changes{{ Create: []*endpoint.Endpoint{ { DNSName: "record2.used.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::2"}, }, }, }}, ) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, sourceRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, sourceRecords.Gauge, map[string]string{"record_type": "aaaa"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, verifiedRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 1, verifiedRecords.Gauge, map[string]string{"record_type": "aaaa"}) } func TestGaugeMetricsWithMixedRecords(t *testing.T) { ctrl := newMixedRecordsFixture() assert.NoError(t, ctrl.RunOnce(t.Context())) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 534, sourceRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 324, sourceRecords.Gauge, map[string]string{"record_type": "aaaa"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, sourceRecords.Gauge, map[string]string{"record_type": "cname"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 11, sourceRecords.Gauge, map[string]string{"record_type": "srv"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 5334, registryRecords.Gauge, map[string]string{"record_type": "a"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 324, registryRecords.Gauge, map[string]string{"record_type": "aaaa"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 0, registryRecords.Gauge, map[string]string{"record_type": "mx"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 43, registryRecords.Gauge, map[string]string{"record_type": "ptr"}) } func newMixedRecordsFixture() *Controller { configuredEndpoints := testutils.GenerateTestEndpointsByType(map[string]int{ endpoint.RecordTypeA: 534, endpoint.RecordTypeAAAA: 324, endpoint.RecordTypeCNAME: 2, endpoint.RecordTypeTXT: 56, endpoint.RecordTypeSRV: 11, endpoint.RecordTypeNS: 3, }) providerEndpoints := testutils.GenerateTestEndpointsByType(map[string]int{ endpoint.RecordTypeA: 5334, endpoint.RecordTypeAAAA: 324, endpoint.RecordTypeCNAME: 23, endpoint.RecordTypeTXT: 6, endpoint.RecordTypeSRV: 25, endpoint.RecordTypeNS: 1, endpoint.RecordTypePTR: 43, }) cfg := externaldns.NewConfig() cfg.Registry = externaldns.RegistryNoop cfg.ManagedDNSRecordTypes = endpoint.KnownRecordTypes source := new(testutils.MockSource) source.On("Endpoints").Return(configuredEndpoints, nil) provider := &filteredMockProvider{ RecordsStore: providerEndpoints, } r, _ := registryfactory.Select(cfg, provider) return &Controller{ Source: source, Registry: r, Policy: &plan.SyncPolicy{}, DomainFilter: endpoint.NewDomainFilter([]string{}), ManagedRecordTypes: cfg.ManagedDNSRecordTypes, } } func BenchmarkGaugeMetricsWithMixedRecords(b *testing.B) { ctrl := newMixedRecordsFixture() for b.Loop() { if err := ctrl.RunOnce(b.Context()); err != nil { b.Fatal(err) } } } ================================================ FILE: docs/20190708-external-dns-incubator.md ================================================ # Move ExternalDNS out of Kubernetes incubator - [Summary](#summary) - [Motivation](#motivation) - [Goals](#goals) - [Proposal](#proposal) - [Details](#details) - [Graduation Criteria](#graduation-criteria) - [Maintainers](#maintainers) - [Release process, artifacts](#release-process-artifacts) - [Risks and Mitigations](#risks-and-mitigations) ## Summary [ExternalDNS](https://github.com/kubernetes-sigs/external-dns) is a project that synchronizes Kubernetes' Services, Ingresses and other Kubernetes resources to DNS backends for several DNS providers. The project was started as a Kubernetes Incubator project in February 2017 and being the Kubernetes incubation initiative officially over, the maintainers want to propose the project to be moved to the kubernetes GitHub organization or to kubernetes-sigs, under the sponsorship of sig-network. ## Motivation ExternalDNS started as a community project with the goal of unifying several existing projects that were trying to solve the same problem: create DNS records for Kubernetes resources on several DNS backends. When the project was proposed (see the [original discussion](https://github.com/kubernetes/kubernetes/issues/28525#issuecomment-270766227)), there were at least 3 existing implementations of the same functionality: - Mate - [https://github.com/linki/mate](https://github.com/linki/mate) - DNS-controller from kops - [https://github.com/kubernetes/kops/tree/HEAD/dns-controller](https://github.com/kubernetes/kops/tree/HEAD/dns-controller) - Route53-kubernetes - [https://github.com/wearemolecule/route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes) ExternalDNS' goal from the beginning was to provide an officially supported solution to those problems. After two years of development, the project is still in the kubernetes-sigs. The incubation has been officially discontinued and to quote @thockin "Incubator projects should either become real projects in Kubernetes, shut themselves down, or move elsewhere" (see original thread [google group](https://groups.google.com/forum/#!topic/kubernetes-sig-network/fvpDC_nxtEM)). This KEP proposes to move ExternalDNS to the main Kubernetes organization or kubernetes-sigs. The "Proposal" section details the reasons behind it. ### Goals The only goal of this KEP is to establish consensus regarding the future of the ExternalDNS project and determine where it belongs. ## Proposal This KEP is about moving External DNS out of the Kubernetes incubator. This section will cover the reasons why External DNS is useful and what the community would miss in case the project would be discontinued or moved under another organization. External DNS... - Is the de facto solution to create DNS records for several Kubernetes resources. - Is a vital component to achieve an experience close to a PaaS that many Kubernetes users try to replicate on top of Kubernetes, by allowing to automatically create DNS records for web applications. - Supports already 18 different DNS providers including all major public clouds (AWS, Azure, GCP). Given that the kubernetes-sigs organization will eventually be shut down, the possible alternatives to moving to be an official Kubernetes project are the following: - Shut down the project - Move the project elsewhere We believe that those alternatives would result in a worse outcome for the community compared to moving the project to any of the other official Kubernetes organizations. In fact, shutting down ExternalDNS can cause: - The community to rebuild the same solution as already happened multiple times before the project was launched. Currently ExternalDNS is easy to be found, referenced in many articles/tutorials and for that reason not exposed to that risk. - Existing users of the projects to be left without a future proof working solution. Moving the ExternalDNS project outside of Kubernetes projects would cause: - Problems (re-)establishing user trust which could eventually lead to fragmentation and duplication. - It would be hard to establish in which organization the project should be moved to. - Lack of resources to test, lack of issue management via automation. For those reasons, we propose to move ExternalDNS out of the Kubernetes incubator, to live either under the kubernetes or kubernetes-sigs organization to keep being a vital part of the Kubernetes ecosystem. ## Details ### Graduation Criteria ExternalDNS is a two years old project widely used in production by many companies. The implementation for the three major cloud providers (AWS, Azure, GCP) is stable, not changing its logic and the project is being used in production by many company using Kubernetes. We have evidence that many companies are using ExternalDNS in production, but it is out of scope for this proposal to collect a comprehensive list of companies. The project was quoted by a number of tutorials on the web, including the [official tutorials from AWS](https://aws.amazon.com/blogs/opensource/unified-service-discovery-ecs-kubernetes/). ExternalDNS can't be considered to be "done": while the core functionality has been implemented, there is lack of integration testing and structural changes that are needed. Those are identified in the project roadmap, which is roughly made of the following items: - Decoupling of the providers - Implementation proposal - Development - Bug fixing and performance optimization (i.e. rate limiting on cloud providers) - Integration testing suite, to be implemented at least for the "stable" providers For those reasons, we consider ExternalDNS to be in Beta state as a project. We believe that once the items mentioned above will be implemented, the project can reach a declared GA status. There are a number of other factors that need to be covered to fully describe the state of the project, including who are the maintainers, the way we release and manage the project and so on. #### Maintainers The project has the following maintainers: - hjacobs - Raffo - linki - njuettner The list of maintainers shrunk over time as people moved out of the original development team (all the team members were working at Zalando at the time of project creation) and the project required less work. The high number of providers contributed to the project pose a maintainability challenge: it is hard to bring the providers forward in terms of functionalities or even test them. The maintainers believe that the plan to transform the current Provider interface from a Go interface to an API will allow for enough decoupling and to hand over the maintenance of those plugins to the contributors themselves, see the risk and mitigations section for further details. ### Release process, artifacts The project uses the free quota of TravisCI to run tests for the project. The release pipeline for the project is currently fully owned by Zalando. It runs on the internal system of the company (closed source) which external maintainers/users can't access and that pushes images to the publicly accessible docker registry available at the URL `registry.opensource.zalan.do`. The docker registry service is provided as best effort with no sort of SLA and the maintainers team openly suggests the users to build and maintain their own docker image based on the provided Dockerfiles. Providing a vanity URL for the docker images was considered a non goal till now, but the community seems to be wanting official images from a GCR domain, similarly to what is available for other parts of official Kubernetes projects. ExternalDNS does not follow a specific release cycle. Releases are made often when there are major contributions (i.e. new providers) or important bug fixes. That said, the default branch is considered stable and can be used as well to build images. ### Risks and Mitigations The following are risks that were identified: - Low number of maintainers: we are currently facing issues keeping up with the number of pull requests and issues giving the low number of maintainers. The list of maintainers already shrunk from 8 maintainers to 4. - Issues maintaining community contributed providers: we often lack access to external providers (i.e. InfoBlox, etc.) and this means that we cannot verify the implementations and/or run regression tests that go beyond unit testing. - Somewhat low quality of releases due to lack of integration testing. We think that the following actions will constitute appropriate mitigations: - Decoupling the providers via an API will allow us to resolve the problem of the providers. Being the project already more than 2 years old and given that there are 18 providers implemented, we possess enough information to define an API that we can be stable in a short timeframe. - Once this is stable, the problem of testing the providers can be deferred to be a provider's responsibility. This will also reduce the scope of External DNS core code, which means that there will be no need for a further increase of the maintaining team. - We added integration testing for the main cloud providers to the roadmap for the 1.0 release to make sure that we cover the mostly used ones. - We believe that this item should be tackled independently from the decoupling of providers as it would be capable of generating value independently from the result of the decoupling efforts. - With the move to the Kubernetes incubation, we hope that we will be able to access the testing resources of the Kubernetes project. ================================================ FILE: docs/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - docs ================================================ FILE: docs/advanced/configuration-precedence.md ================================================ ## Annotations vs. CLI Flags Precedence ExternalDNS configuration can come from these sources: resource annotations, CLI flags, environment variables, and defaults. The effective value is determined by the following precedence order: ```mermaid flowchart TD A[1. Resource Annotations] -->|Override| Result B[2. CLI Flags] -->|Used if no annotation| Result C[3. Environment Variables] -->|May override defaults
and in some cases flags/annotations| Result D[4. Defaults] -->|Fallback| Result subgraph Flags B1[Filter Flags: --flag-with-filter] -->|Define scope
Annotations outside scope ignored| B B2[Non-filter Flags] -->|Apply if no annotation| B end Result[Effective ExternalDNS Configuration] A --> Result B --> Result D --> Result ``` 1. **Annotations** - Most configuration options can be set via annotations on supported resources. - When present, annotations override the corresponding CLI flags and defaults. - Exception: should be documented. - Exception: ignored when applied to `kind: DNSEndpoint` - Exception: filter flags (e.g. `--service-type-filter`, `--source`) define the *scope* of resources considered. 2. **CLI Flags** - Non-filter flags apply if no annotation overrides them. - Filter flags (`--source`, `--service-type-filter`, `--*-filter`) limit which resources are processed. - Annotations outside the defined scope are ignored. - If a resource is excluded by a filter, annotations configured on the resource or defaults will not be applied. 3. **Environment Variables** - May override defaults, and in some cases may take precedence over CLI flags and annotations. - Behavior depends on how the variable is mapped in the code. Whether or not it replicates CLI flag or provider specific. Example: `kubectl` or `cloudflare`. 4. **Defaults** - If none of the above specify a value, ExternalDNS falls back to its defaults. ================================================ FILE: docs/advanced/domain-filter.md ================================================ --- tags: - advanced - area/domain-filter - domain-filter --- # Domain Filter > **Important:** Domain filter flags express application-level intent — they are not an > enforcement boundary. Credentials (IAM policies, API token scopes, ACLs) are the real > enforcement boundary. A misconfigured or missing flag will expose all zones the credentials > can reach. Always scope API keys or IAM roles to only the specific zones external-dns manages; > use these flags to complement that boundary, not replace it. ExternalDNS has two modes for selecting which domains it manages: **plain domain filter** and **regex domain filter**. Using any of the regex filter flags enables the **regex domain filter** mode, which overrides and ignores the **plain domain filter** flags. **Domain filter flags**: | Flag | Mode | Semantics | | -------------------------- | ----- | -------------------------------------------------------------------------------------- | | `--domain-filter` | plain | Suffix match — includes a domain and all its subdomains | | `--exclude-domains` | plain | Suffix match — excludes a domain or subdomain from `--domain-filter` | | `--regex-domain-filter` | regex | Full regex match — **overrides `--domain-filter`** when set | | `--regex-domain-exclusion` | regex | Regex that removes matches from `--regex-domain-filter`; can also be used standalone | All of these flags are applied to DNS record names. Providers that partition zones before managing records (e.g., PowerDNS) also apply the filter to zone names. ## Plain domain filter Specify one or more domain suffixes. ExternalDNS will manage any record whose name ends with one of the provided values. ```sh --domain-filter=example.com --domain-filter=other.org ``` To exclude specific subdomains use `--exclude-domains`: ```sh --domain-filter=example.com --exclude-domains=staging.example.com ``` ## Regex domain filter `--regex-domain-filter` accepts a Go RE2 regular expression. Use it when suffix matching is not expressive enough — for example, to select zones by region name pattern. ```sh --regex-domain-filter='\.org$' ``` Use `--regex-domain-exclusion` to reject zones that would otherwise match: ```sh --regex-domain-filter='^([\w-]+\.)*example\.com$' --regex-domain-exclusion='^staging\.' ``` ### Matching logic Exclusion is always checked first: 1. If `--regex-domain-exclusion` matches → **rejected** 2. If `--regex-domain-filter` matches → **accepted** 3. If only `--regex-domain-exclusion` is set (the domain did not match) → **accepted** (exclusion-only mode) 4. If `--regex-domain-filter` is set (the domain did not match) → **rejected** ```mermaid flowchart TD A["Domain candidate"] --> B{"Is regex filter
or exclusion set?"} B -- "No (use plain filters)" --> C{"Matches
--domain-filter?"} C -- "No" --> REJECT["❌ Rejected"] C -- "Yes" --> D{"Matches
--exclude-domains?"} D -- "Yes" --> REJECT D -- "No" --> ACCEPT["✅ Accepted"] B -- "Yes (regex mode)" --> E{"Matches
--regex-domain-exclusion?"} E -- "Yes" --> REJECT E -- "No" --> F{"--regex-domain-filter set?"} F -- "No (exclusion-only mode)" --> ACCEPT F -- "Yes" --> G{"Matches
--regex-domain-filter?"} G -- "Yes" --> ACCEPT G -- "No" --> REJECT ``` ### Examples **Include only `.org` domains:** ```sh --regex-domain-filter='\.org$' ``` **Include a specific set of domains:** ```sh --regex-domain-filter='(?:foo|bar)\.org$' ``` **Include with exclusion:** ```sh # foo.org, bar.org, a.example.foo.org → accepted # example.foo.org, example.bar.org → rejected --regex-domain-filter='(?:foo|bar)\.org$' --regex-domain-exclusion='^example\.(?:foo|bar)\.org$' ``` **Production environment with temp exclusion:** ```sh --regex-domain-filter='\.prod\.example\.com$' --regex-domain-exclusion='^temp-' ``` **Exclusion-only (accept everything except a pattern):** ```sh --regex-domain-exclusion='test-v1\.3\.example-test\.in' ``` **Exclude a complex pattern:** ```sh --regex-domain-exclusion='^(internal|private)-.*\.example\.com$' ``` ### Zone-partitioning pitfall: `+` vs `*` The most common misconfiguration when filtering zones (not just records) is using `[\w-]+` (one or more) instead of `([\w-]+\.)*` (zero or more) for the label-prefix group. Because `+` requires at least one repetition: - The **apex zone** (`example.com`) has no label prefix and will never match. - **Multi-label subdomain zones** (`long.sub.example.com`) contain dots that `[\w-]+` cannot span. Both zone types end up unmanaged, causing ExternalDNS to log `Ignoring Endpoint` for every record they contain with no other indication of what went wrong. | Regex | Matches | Misses | |-----------------------------|----------------------------------------------------------|---------------------------------------| | `^[\w-]+\.example\.com$` | `sub.example.com` | `example.com`, `long.sub.example.com` | | `^([\w-]+\.)*example\.com$` | `example.com`, `sub.example.com`, `long.sub.example.com` | — | Always use `*` so the apex matches on zero repetitions and subdomain zones match on one or more. ### Multi-region example ```sh --regex-domain-filter='^([\w-]+\.)*(?:us-east-1|eu-central-1)\.example\.com$' --regex-domain-exclusion='^staging\.' ``` | Zone | Result | |---------------------------------|-------------| | `us-east-1.example.com` | managed | | `prod.us-east-1.example.com` | managed | | `eu-central-1.example.com` | managed | | `staging.us-east-1.example.com` | excluded | | `other.com` | not managed | ## Notes - **Regex syntax**: Standard Go RE2. Escape dots (`\.`) and use anchors (`^`, `$`) where precision matters. - **Case sensitivity**: Matching is case-sensitive. Domains are lowercased and trailing dots stripped before matching. - **IDN / Unicode**: Domains are converted to Unicode form (IDNA) before matching, so patterns against emoji or Unicode labels work as expected. - **Mutual exclusivity**: Once a regex flag is non-empty, list-based filters are ignored entirely. ## Debugging If records are silently dropped, look for `Ignoring Endpoint` in the logs — this means no managed zone matched the record. To isolate whether the domain filter is the cause, temporarily switch to `--domain-filter` with the plain suffix; if records reappear, the regex is the problem. ## Testing your regex Before deploying, validate the regex against real zone names: - [regex101.com](https://regex101.com/) — interactive tester; select the **Golang** flavour to match Go's RE2 engine exactly. Paste each zone name on a separate line and enable the **global** flag. - AI assistants (ChatGPT, Claude, DeepWiki, etc.) — describe the zones you want to match/exclude and ask for a regex; always verify the output in regex101 before use. ## See Also - [Flags reference](../flags.md) — `--domain-filter`, `--exclude-domains`, `--regex-domain-filter`, `--regex-domain-exclusion` - [AWS filters tutorial](../tutorials/aws-filters.md) — filter flag interaction table - [FAQ](../faq.md) — general configuration questions ================================================ FILE: docs/advanced/events.md ================================================ --- tags: ["advanced", "area/events", "events"] --- # Kubernetes Events in External-DNS External-DNS manages DNS records dynamically based on Kubernetes resources like Services and Ingresses. Emitting Kubernetes Events provides a lightweight observable way for users and systems to understand what External-DNS is doing, especially in production environments where DNS correctness is essential. > Note; events is currently alpha feature. Functionality is limited and subject to change ## ✨ Why Events Matter Kubernetes Events enable External-DNS to provide real-time feedback to users and controllers, complementing logs with a simpler way to track DNS changes. This enhances debugging, monitoring, and automation around DNS operations. ### Use Cases of Emitting Events | Use Case | Description | |---------------------------------------------------|--------------------------------------------------------------------------------------------------------------| | **DNS Record Visibility** | Events show what DNS records were created, updated, or deleted (e.g., `Created A record "api.example.com"`). | | **Developer Feedback** | Users deploying Ingresses or Services can see if External-DNS processed their resource. | | **Surface Errors, Debugging and Troubleshooting** | Easily identify resource misannotations, sync failures, or IAM permission issues. | | **Error Reporting** | Emit warning events when record sync fails due to provider issues, duplicate records, or misconfigurations. | | **Integration with Alerting/Automation/Auditing** | This enables automated remediation or notifications when DNS sync fails or changes unexpectedly. | | **Observability** | Trace why a DNS record wasn’t created. | | **Alerting/automation** | Trigger actions based on failed events. | | **Operator and Developer feedback** | It removes the black-box feeling of "I deployed an Ingress, but why doesn’t the DNS work?" | ## Consuming Events You can observe External-DNS events using: ```sh kubectl describe service kubectl get events --field-selector involvedObject.kind=Service kubectl get events --field-selector type=Normal|Warning kubectl get events --field-selector reason=RecordReady|RecordDeleted|RecordError kubectl get events --field-selector reportingComponent=external-dns ``` Or integrate with tools like: - Prometheus (via event exporters) - Loki/Fluentd for log/event aggregation - Argo CD / Flux for GitOps monitoring ### Practices for Understanding Events - **Action field**: Events include a short label describing the `Action`, such as `Created`, `Updated`, `Deleted`, or `FailedSync` - **Reason field**: Events include a short label `Reason` is why the action was taken, such as `RecordReady`, `RecordDeleted`, or `RecordError`. - **Type field**: - `Normal` means the operation succeeded (e.g., a DNS record was created). - `Warning` indicates a problem (e.g., DNS sync failed due to configuration or provider issues). - **Linked** resource: Events are attached to the relevant Kubernetes resource (like an `Ingress` or `Service`), so you can view them with tools like `kubectl describe`. - **Event noise**: If you see repeated identical events, it may indicate a misconfiguration or an issue worth investigating. ### Sequence Overview: External-DNS Endpoint Reconciliation and Event Emission The following sequence diagram illustrates the core workflow of how External-DNS processes endpoints, applies DNS changes, and emits Kubernetes events: 1. **Endpoint Collection** The `Source` component generates `Endpoint` objects, each linked to a `ReferenceObject` (such as a Service or Ingress). 2. **Plan Construction** A `Plan` aggregates multiple `Endpoints` and prepares a list of desired DNS changes. 3. **Change Application** The `Plan` sends the changes to a DNS `Provider`, which attempts to apply them. Each `Endpoint` is labeled with the result: `Success`, `Failed`, or `Skip`. 4. **Event Emission** Based on the result, an `Event` is created for each `Endpoint`, referencing the original `ReferenceObject`. These events are then emitted via the `EventEmitter`. This sequence ensures DNS records are managed declaratively and provides real-time visibility into the system’s behavior through Kubernetes Events. ```mermaid sequenceDiagram participant Source participant ReferenceObject participant Endpoint participant Plan participant Event participant Provider participant EventEmitter loop Process each Endpoint Source->>Endpoint: Add Endpoint with Reference end Endpoint-->>ReferenceObject: Contains Plan-->>Endpoint: Contains multiple loop Apply Changes Plan->>Provider: Apply Endpoint Changes Provider-->>Endpoint: Label with Skip/Success/Failed Provider-->>Plan: Return Result end loop Process each Event Provider->>Plan: Label with Skip/Success/Failed Plan-->>Event: Construct Event Event-->>ReferenceObject: Contains Event->>EventEmitter: Emit end ``` ### Caveats - Events are ephemeral (default retention is ~1 hour). - They are best-effort and not guaranteed to be delivered or stored long-term. - Not a substitute for logging or metrics, but complementary. ## Supported Sources Events are emitted for all sources that External-DNS supports. Event support is being rolled out progressively — if a source does not yet emit events, it may in the future. See the [sources reference](../sources/index.md#available-sources) for the full list and per-source event support status. ================================================ FILE: docs/advanced/fqdn-templating.md ================================================ --- tags: ["advanced", "area/fqdn", "fqdn", "templating"] --- # FQDN Templating Guide ## What is FQDN Templating? **FQDN templating** is a feature that allows to dynamically construct Fully Qualified Domain Names (FQDNs) using a Go templating engine. Instead of relying solely on annotations or static names, you can use metadata from Kubernetes objects—such as service names, namespaces, and labels—to generate DNS records programmatically and dynamically. This is useful for: - Creating consistent naming conventions across environments. - Reducing boilerplate annotations. - Supporting multi-tenant or dynamic environments. - Migrating from one DNS scheme to another - Supporting multiple variants, such as a regional one and then one that doesn't or similar. ## How It Works ExternalDNS has a flag: `--fqdn-template`, which defines a Go template for rendering the desired DNS names. The template uses the following data from the source object (e.g., a `Service` or `Ingress`): | Field | Description | How to Access | |:--------------|:-------------------------------------------------|:-------------------------------------------------------| | `Kind` | Object kind (e.g., `Service`, `Pod`, `Ingress`) | `{{ .Kind }}` | | `APIVersion` | API version (e.g., `v1`, `networking.k8s.io/v1`) | `{{ .APIVersion }}` | | `Name` | Name of the object (e.g., service) | `{{ .Name }}` | | `Namespace` | Namespace of the object | `{{ .Namespace }}` | | `Labels` | Map of labels applied to the object | `{{ .Labels.key }}` or `{{ index .Labels "key" }}` | | `Annotations` | Map of annotations | `{{ index .Annotations "key" }}` | | `Spec` | Object spec with type-specific fields | `{{ .Spec.Type }}`, `{{ index .Spec.Selector "app" }}` | | `Status` | Object status with type-specific fields | `{{ .Status.LoadBalancer.Ingress }}` | To explore all available fields for an object type, use `kubectl explain`: ```bash # View all fields for a Service recursively. kubectl explain service --api-version=v1 --recursive # View all fields for a Ingress recursively. kubectl explain ingress --api-version=networking.k8s.io/v1 --recursive # View a specific field path. The dot notation is for field path. kubectl explain service.spec.selector kubectl explain pod.spec.containers ``` ## Supported Sources | Source | Description | FQDN Supported | FQDN Combine | |:-----------------------|:----------------------------------------------------------------|:--------------:|:------------:| | `ambassador-host` | Queries Ambassador Host resources for endpoints. | No | No | | `connector` | Queries a custom connector source for endpoints. | No | No | | `contour-httpproxy` | Queries Contour HTTPProxy resources for endpoints. | Yes | Yes | | `crd` | Queries Custom Resource Definitions (CRDs) for endpoints. | No | No | | `empty` | Uses an empty source, typically for testing or no-op scenarios. | No | No | | `f5-transportserver` | Queries F5 TransportServer resources for endpoints. | No | No | | `f5-virtualserver` | Queries F5 VirtualServer resources for endpoints. | No | No | | `fake` | Uses a fake source for testing purposes. | No | No | | `gateway-grpcroute` | Queries GRPCRoute resources from the Gateway API. | Yes | No | | `gateway-httproute` | Queries HTTPRoute resources from the Gateway API. | Yes | No | | `gateway-tcproute` | Queries TCPRoute resources from the Gateway API. | Yes | No | | `gateway-tlsroute` | Queries TLSRoute resources from the Gateway API. | No | No | | `gateway-udproute` | Queries UDPRoute resources from the Gateway API. | No | No | | `gloo-proxy` | Queries Gloo Proxy resources for endpoints. | No | No | | `ingress` | Queries Kubernetes Ingress resources for endpoints. | Yes | Yes | | `istio-gateway` | Queries Istio Gateway resources for endpoints. | Yes | Yes | | `istio-virtualservice` | Queries Istio VirtualService resources for endpoints. | Yes | Yes | | `kong-tcpingress` | Queries Kong TCPIngress resources for endpoints. | No | No | | `node` | Queries Kubernetes Node resources for endpoints. | Yes | Yes | | `openshift-route` | Queries OpenShift Route resources for endpoints. | Yes | Yes | | `pod` | Queries Kubernetes Pod resources for endpoints. | Yes | Yes | | `service` | Queries Kubernetes Service resources for endpoints. | Yes | Yes | | `skipper-routegroup` | Queries Skipper RouteGroup resources for endpoints. | Yes | Yes | | `traefik-proxy` | Queries Traefik IngressRoute resources for endpoints. | No | No | ## Custom Functions | Function | Description | Example | |:-------------|:------------------------------------------------------|:-----------------------------------------------------------------------------------| | `contains` | Check if `substr` is in `string` | `{{ contains "hello" "ell" }} → true` | | `isIPv4` | Validate an IPv4 address | `{{ isIPv4 "192.168.1.1" }} → true` | | `isIPv6` | Validate an IPv6 address (including IPv4-mapped IPv6) | `{{ isIPv6 "2001:db8::1" }} → true`
`{{ isIPv6 "::FFFF:192.168.1.1" }} → true` | | `replace` | Replace `old` with `new` | `{{ replace "l" "w" "hello" }} → hewwo` | | `trim` | Remove leading and trailing spaces | `{{ trim " hello " }} → hello` | | `toLower` | Convert to lowercase | `{{ toLower "HELLO" }} → hello` | | `trimPrefix` | Remove the leading `prefix` | `{{ trimPrefix "hello" "h" }} → ello` | | `trimSuffix` | Remove the trailing `suffix` | `{{ trimSuffix "hello" "o" }} → hell` | | `hasKey` | Check if a key exists in a map | `{{ hasKey .Labels "app" }} → true` | | `fromJson` | Parse a JSON string into a value | `{{ index (fromJson "{\"env\":\"prod\"}") "env" }} → prod` | --- ## Example Usage > These examples should provide a solid foundation for implementing FQDN templating in your ExternalDNS setup. > If you have specific requirements or encounter issues, feel free to explore the issues or update this guide. ### Basic Usage ```yml apiVersion: v1 kind: Service metadata: name: my-service namespace: my-namespace ``` ```sh external-dns \ --provider=aws \ --source=service \ --fqdn-template="{{ .Name }}.example.com,{{ .Name }}.{{ .Namespace }}.example.tld" # This will result in DNS entries like >route53> my-service.example.com >route53> my-service.my-namespace.example.tld ``` ### With Namespace ```yml --- apiVersion: v1 kind: Service metadata: name: my-service namespace: default --- apiVersion: v1 kind: Service metadata: name: other-service namespace: kube-system ``` ```yml args: --fqdn-template="{{.Name}}.{{.Namespace}}.example.com" # This will result in DNS entries like # route53> my-service.default.example.com # route53> other-service.kube-system.example.com ``` ### Using Labels in Templates You can also utilize labels in your FQDN templates to create more dynamic DNS entries. Assuming your service has: ```yml apiVersion: v1 kind: Service metadata: name: my-service labels: environment: staging ``` ```yml args: --fqdn-template="{{ .Labels.environment }}.{{ .Name }}.example.com" # This will result in DNS entries like # route53> staging.my-service.example.com ``` ### Multiple FQDN Templates ExternalDNS allows specifying multiple FQDN templates, which can be useful when you want to create multiple DNS entries for a single service or ingress. > Be cautious, as this will create multiple DNS records per resource, potentially increasing the number of API calls to your DNS provider. ```yml args: --fqdn-template={{.Name}}.example.com,{{.Name}}.svc.example.com ``` ### Conditional Templating combined with Annotations processing In scenarios where you want to conditionally generate FQDNs based on annotations, you can use Go template functions like or to provide defaults. ```yml args: - --combine-fqdn-annotation # this is required to combine FQDN templating and annotation processing - --fqdn-template={{ or .Annotations.dns "invalid" }}.example.com - --exclude-domains=invalid.example.com ``` ### Using Annotations for FQDN Templating This example demonstrates how to use annotations in Kubernetes objects to dynamically generate Fully Qualified Domain Names (FQDNs) using the --fqdn-template flag in ExternalDNS. The Service object includes an annotation dns.company.com/label with the value my-org-tld-v2. This annotation is used as part of the FQDN template to construct the DNS name. ```yml apiVersion: v1 kind: Service metadata: name: nginx-v2 namespace: my-namespace annotations: dns.company.com/label: my-org-tld-v2 spec: type: ClusterIP clusterIP: None ``` The --fqdn-template flag is configured to use the annotation value (dns.company.com/label) and append the namespace and a custom domain (company.local) to generate the FQDN. ```yml args: --source=service --fqdn-template='{{ index .ObjectMeta.Annotations "dns.company.com/label" }}.{{ .Namespace }}.company.local' # For the given Service object, the resulting FQDN will be: # route53> my-org-tld-v2.my-namespace.company.local ``` ### DNS Scheme Migration If you're transitioning from one naming convention to another (e.g., from svc.cluster.local to svc.example.com), --fqdn-template allows you to generate the new records alongside or in place of the old ones — without requiring changes to your Kubernetes manifests. ```yml args: - --fqdn-template='{{.Name}}.new-dns.example.com' ``` This helps automate DNS record migration while maintaining service continuity. ### Using Kind for Conditional Templating When processing multiple resource types, use `.Kind` to apply templates conditionally: ```yml args: --fqdn-template='{{ if eq .Kind "Service" }}{{ .Name }}.svc.example.com{{ end }}' # Only Services will get DNS entries, Pods and other resources will be skipped ``` You can also handle multiple kinds in one template: ```yml args: --fqdn-template='{{ if eq .Kind "Service" }}{{ .Name }}.svc.example.com{{ end }}{{ if eq .Kind "Pod" }}{{ .Name }}.pod.example.com{{ end }}' ``` ### Using Spec Fields Access type-specific spec fields for advanced filtering: ```yml # Only ExternalName services args: --fqdn-template='{{ if eq .Kind "Service" }}{{ if eq .Spec.Type "ExternalName" }}{{ .Name }}.external.example.com{{ end }}{{ end }}' ``` ```yml apiVersion: v1 kind: Service metadata: name: web-frontend spec: selector: app: nginx # This selector will be used in the FQDN tier: frontend ports: - port: 80 --- apiVersion: v1 kind: Service metadata: name: database spec: selector: tier: backend # Won't generate FQDN - no "app" key in selector ports: - port: 5432 ``` ```yml # Services with specific selector args: --fqdn-template='{{ if eq .Kind "Service" }}{{ if index .Spec.Selector "app" }}{{ .Name }}.{{ index .Spec.Selector "app" }}.example.com{{ end }}{{ end }}' # Result for web-frontend: web-frontend.nginx.example.com # Result for database: (no FQDN generated - selector has no "app" key) ``` ### Iterating Over Labels with Range Use `range` to iterate over labels and generate multiple FQDNs: ```yml args: --fqdn-template='{{ if eq .Kind "Service" }}{{ range $key, $value := .Labels }}{{ if contains $key "app" }}{{ $.Name }}.{{ $value }}.example.com{{ printf "," }}{{ end }}{{ end }}{{ end }}' ``` This generates an FQDN for each label key containing "app". Note: - `$key` and `$value` are the label key/value pairs - `$.Name` accesses the root object's Name (use `$` inside `range`) - `{{ printf "," }}` separates multiple FQDNs ### Working with Annotations Access a specific annotation: ```yml args: --fqdn-template='{{ index .Annotations "dns.example.com/hostname" }}.example.com' ``` Iterate over annotations and filter by key: ```yml apiVersion: v1 kind: Service metadata: name: my-service annotations: dns.example.com/primary: api.example.com dns.example.com/secondary: api-backup.example.com kubernetes.io/ingress-class: nginx # Won't match - key doesn't contain "dns.example.com/" ``` ```yml args: --fqdn-template='{{ range $key, $value := .Annotations }}{{ if contains $key "dns.example.com/" }}{{ $value }}{{ printf "," }}{{ end }}{{ end }}' # Captures all annotations with keys containing "dns.example.com/" # Result: api.example.com, api-backup.example.com ``` Filter annotations by value: ```yml apiVersion: v1 kind: Service metadata: name: my-service annotations: custom/hostname: api.example.com custom/alias: www.example.com custom/internal: internal.local # Won't match - value doesn't contain ".example.com" ``` ```yml args: --fqdn-template='{{ range $key, $value := .Annotations }}{{ if contains $value ".example.com" }}{{ $value }}{{ printf "," }}{{ end }}{{ end }}' # Captures all annotation values containing ".example.com" # Result: api.example.com, www.example.com ``` Combine annotation key and value filters: ```yml apiVersion: v1 kind: Service metadata: name: my-service annotations: dns/primary: api.example.com dns/secondary: api-backup.example.com other/hostname: internal.other.org # Won't match - value doesn't contain "example.com" logging/level: debug # Won't match - key doesn't contain "dns/" ``` ```yml args: --fqdn-template='{{ if eq .Kind "Service" }}{{ range $k, $v := .Annotations }}{{ if and (contains $k "dns/") (contains $v "example.com") }}{{ $v }}{{ printf "," }}{{ end }}{{ end }}{{ end }}' # Result: api.example.com, api-backup.example.com ``` ### Combining Kind and Label Filters Filter by both Kind and label values: ```yml args: --fqdn-template='{{ if eq .Kind "Pod" }}{{ range $k, $v := .Labels }}{{ if and (contains $k "app") (contains $v "my-service-") }}{{ $.Name }}.{{ $v }}.example.com{{ printf "," }}{{ end }}{{ end }}{{ end }}' # Generates FQDNs only for Pods with labels like app1=my-service-123 # Result: pod-name.my-service-123.example.com ``` ### Multi-Variant Domain Support You can also support regional variants or multi-tenant architectures, where the same service is deployed to different regions or environments: ```yaml --fqdn-template='{{ .Name }}.{{ .Labels.env }}.{{ .Labels.region }}.example.com, {{ if eq .Labels.env "prod" }}{{ .Name }}.my-company.tld{{ end }}' # Generates FQDNs for resources with labels env and region # For a Service named "api" with labels env=prod, region=us-east-1: # Result: api.prod.us-east-1.example.com, api.my-company.tld # For a Service named "api" with labels env=staging, region=eu-west-1: # Result: api.staging.eu-west-1.example.com ``` This is helpful in scenarios such as: - Blue/green deployments across domains - Staging vs. production resolution - Multi-cloud or multi-region failover strategies ## Tips - If `--fqdn-template` is specified, ExternalDNS ignores any `external-dns.alpha.kubernetes.io/hostname` annotations. - You must still ensure the resulting FQDN is valid and unique. - Since Go templates can be error-prone, test your template with simple examples before deploying. Mismatched field names or nil values (e.g., missing labels) will result in errors or skipped entries. ## FAQ ### Can I specify multiple global FQDN templates? Yes, you can. Pass in a comma separated list to --fqdn-template. Beware this will double (triple, etc) the amount of DNS entries based on how many services, ingresses and so on you have and will get you faster towards the API request limit of your DNS provider. ### Where to find template syntax - [Go template syntax](https://pkg.go.dev/text/template) - Official reference for template syntax, actions, and pipelines - [Go func builtins](https://github.com/golang/go/blob/master/src/text/template/funcs.go#L39-L63) ### FQDN Templating, Helm and improper templating syntax The user encountered errors due to improper templating syntax: ```yml extraArgs: - --fqdn-template={{name}}.uat.example.com ``` The correct syntax should include a dot prefix: `{{ .Name }}`. Additionally, when using Helm's `tpl` function, it's necessary to escape the braces to prevent premature evaluation: ```yml extraArgs: - --fqdn-template={{ `{{ .Name }}.uat.example.com` }} ``` ### Handling Subdomain-Only Hostnames In [Issue #1872](https://github.com/kubernetes-sigs/external-dns/issues/1872), it was observed that ExternalDNS ignores the `--fqdn-template` when the ingress host field is set to a subdomain (e.g., foo) without a full domain. The expectation was that the template would still apply, generating entries like `foo.bar.example.com.` This highlights a limitation to be aware of when designing FQDN templates. > :warning: This is currently not supported ! User would expect external-dns to generate a dns record according to the fqdnTemplate > e.g. if the ingress name: foo and host: foo is created while fqdnTemplate={{.Name}}.bar.example.com then a dns record foo.bar.example.com should be created ```yml apiVersion: extensions/v1beta1 kind: Ingress metadata: name: foo spec: rules: - host: foo http: paths: - backend: serviceName: foo servicePort: 80 path: / ``` ### Combining FQDN Template with Annotations In [Issue #3318](https://github.com/kubernetes-sigs/external-dns/issues/3318), a question was raised about the interaction between --fqdn-template and --combine-fqdn-annotation. The discussion clarified that when both flags are used, ExternalDNS combines the FQDN generated from the template with the annotation value, providing flexibility in DNS name construction. ### Using Annotations for Dynamic FQDNs In [Issue #2627](https://github.com/kubernetes-sigs/external-dns/issues/2627), a user aimed to generate DNS entries based on ingress annotations: ```yml args: - --fqdn-template={{.Annotations.hostname}}.example.com - --combine-fqdn-annotation - --domain-filter=example.com ``` By setting the hostname annotation in the ingress resource, ExternalDNS constructs the FQDN accordingly. This approach allows for dynamic DNS entries without hardcoding hostnames. ### Using a Node's Addresses for FQDNs ```yml args: - --fqdn-template="{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.example.com" ``` This is a complex template that iternates through a list of a Node's Addresses and creates a FQDN with public IPv4 addresses. ### Using `hasKey` for Safe Label and Annotation Access Unlike `index`, which returns an empty string for both a missing key and a key with an empty value, `hasKey` explicitly checks for key existence. This matters for Kubernetes marker labels (e.g., `service.kubernetes.io/headless: ""`), where an empty value is meaningful. Check for a label before using it in a template: ```yml apiVersion: v1 kind: Service metadata: name: my-service labels: app: nginx ``` ```yml args: - --fqdn-template={{ if hasKey .Labels "app" }}{{ .Name }}.{{ index .Labels "app" }}.example.com{{ end }} # Result: my-service.nginx.example.com ``` This only generates an FQDN when the `app` label is present. Without `hasKey`, `{{ index .Labels "app" }}` would silently return `""` for unlabelled resources, producing an invalid FQDN like `my-service..example.com`. Combine with `Kind` for targeted rules: ```yml args: - --fqdn-template={{ if and (eq .Kind "Service") (hasKey .Labels "tier") }}{{ .Name }}.{{ index .Labels "tier" }}.example.com{{ end }} ``` ### Using `fromJson` to Parse Structured Labels `fromJson` parses a JSON string stored in a label or annotation into a Go value, enabling templates to iterate over structured data. Given a Service with a JSON array of DNS entries in a label: ```yml apiVersion: v1 kind: Service metadata: name: my-service labels: records: '[{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]' ``` Use `hasKey` to guard against a missing label, then iterate with `range` to emit one FQDN per entry: ```yml args: - --fqdn-template={{ if hasKey .Labels "records" }}{{ range $entry := (index .Labels "records" | fromJson) }}{{ index $entry "dns" }},{{ end }}{{ end }} # Result: entry1.internal.tld, entry2.example.tld ``` ================================================ FILE: docs/advanced/import-records.md ================================================ # Import Existing DNS Records Sometimes DNS records are created manually (e.g., through Route53, CloudDNS, or AzureDNS), but you still want ExternalDNS to take ownership of them for ongoing management. This tutorial shows how to “import” such records into ExternalDNS by creating the appropriate TXT records. --- ## Prerequisites * A working Kubernetes cluster * ExternalDNS installed and configured with your DNS provider * Manually created DNS records that you want to manage --- ## Example: Importing a Manually Created A Record Let’s assume you already have the following A record created manually in Route53: ```text grafana.dev.example.com → A record → pointing to NLB ``` This entry is referenced in an Istio Gateway resource but was not created by ExternalDNS. This is how a gateway.yaml file looks like: ```yaml apiVersion: networking.istio.io/v1 kind: Gateway metadata: name: gateway namespace: istio-system spec: selector: istio: gateway servers: - hosts: - grafana.dev.example.com port: name: http number: 80 protocol: HTTP ``` External-dns deployment file: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns namespace: kube-system spec: minReadySeconds: 15 replicas: 2 revisionHistoryLimit: 10 selector: matchLabels: app: external-dns strategy: rollingUpdate: maxSurge: 50% maxUnavailable: 25% type: RollingUpdate template: metadata: labels: app: external-dns spec: automountServiceAccountToken: true containers: - args: - --source=service - --source=ingress - --source=istio-gateway - --domain-filter=dev.example.com. - --provider=aws - --policy=sync - --aws-zone-type=private - --registry=txt - --events - --txt-owner-id=dev.example.com - --log-level=info env: - name: AWS_DEFAULT_REGION value: us-west-2 image: registry.k8s.io/external-dns/external-dns:v0.20.0 imagePullPolicy: IfNotPresent name: external-dns securityContext: fsGroup: 65534 runAsNonRoot: false serviceAccount: external-dns ``` --- ## Step 1: Create Corresponding TXT Records To let ExternalDNS take ownership of the existing A record, you must add TXT records that follow the ExternalDNS format. For example: ```text aaaa-grafana.dev.example.com → TXT → "heritage=external-dns,external-dns/owner=dev.example.com,external-dns/resource=gateway/istio/gateway" cname-grafana.dev.example.com → TXT → "heritage=external-dns,external-dns/owner=dev.example.com,external-dns/resource=gateway/istio/gateway" ``` Note: The easiest way to determine the correct TXT value is to create a dummy record with ExternalDNS. This will generate the required TXT entries, which you can then copy and apply to your manually created records. These TXT records tell ExternalDNS: * Which resource owns the record (`external-dns/resource=...`) (in this case, it's istio) * Which owner identifier is managing it (`external-dns/owner=...`) --- ## Step 2: Verify ExternalDNS Behavior After creating the TXT records, wait for the next reconciliation loop. You should now see ExternalDNS managing the record without errors. * With `policy=sync`: if you remove the entry from the Kubernetes resource (e.g., Istio Gateway), ExternalDNS will also remove the corresponding DNS record from your provider. * With `policy=upsert-only`: ExternalDNS will not delete existing records, even if you remove them from Kubernetes resources. --- ## Notes * TXT records are required because they serve as ownership markers, preventing conflicts between multiple ExternalDNS controllers. * This approach is especially useful during migrations, where DNS records pre-exist but you want to avoid downtime or duplication. --- With this setup, ExternalDNS will manage both newly created and previously existing records in a consistent way. ================================================ FILE: docs/advanced/nat64.md ================================================ # Configure NAT64 DNS Records Some NAT64 configurations are entirely handled outside the Kubernetes cluster, therefore Kubernetes does not know anything about the associated IPv4 addresses. ExternalDNS should also be able to create A records for those cases. Therefore, we can configure `nat64-networks`, which **must** be a /96 network. You can also specify multiple `nat64-networks` for more complex setups. This creates an additional A record with a NAT64-translated IPv4 address for each AAAA record pointing to an IPv6 address within the given `nat64-networks`. This can be configured with the following flag passed to the operator binary. You can also pass multiple `nat64-networks` by using a comma as seperator. ```sh --nat64-networks="2001:db8:96::/96" ``` ## Setup Example We use an external NAT64 resolver and SIIT (Stateless IP/ICMP Translation). Therefore, our nodes only have IPv6 IP adresses but can reach IPv4 addresses *and* can be reached via IPv4. Outgoing connections are a classic NAT64 setup, where all IPv6 addresses gets translated to a small pool of IPv4 addresses. Incoming connnections are mapped on a different IPv4 pool, e.g. `198.51.100.0/24`, which can get translated one-to-one to IPv6 addresses. We dedicate a `/96` network for this, for example `2001:db8:96::/96`, so `198.51.100.0/24` can translated to `2001:db8:96::c633:6400/120`. Note: `/120` IPv6 network has exactly as many IP addresses as `/24` IPv4 network. Therefore, the `/96` network can be configured as `nat64-networks`. This means, that `2001:0DB8:96::198.51.100.10` or `2001:db8:96::c633:640a` can be translated to `198.51.100.10`. Any source can point a record to an IPv6 address within the given `nat64-networks`, for example `2001:db8:96::c633:640a`. This creates by default an AAAA record and - if `nat64-networks` is configured - also an A record with `198.51.100.10` as target. ================================================ FILE: docs/advanced/operational-best-practices.md ================================================ --- tags: - advanced - operations - performance - configuration --- # Operational Best Practices This guide covers configuration recommendations for running external-dns reliably in production. It focuses on the interaction between flags and real-world deployment scenarios — scope, memory, scale, and observability — and complements the per-feature reference pages linked throughout. > If you have operational experience or best practices not covered here, please open a proposal > PR to share it with the community. ## Production Readiness Checklist Use this as a quick review before deploying to production. Each item links to the relevant section below. **Resource scope** - [ ] Set [`--service-type-filter`](#reducing-the-informer-scope) to only the service types you actually publish (e.g., `LoadBalancer`). The default watches Pods, EndpointSlices, and Nodes unnecessarily for most deployments. - [ ] Add [`--label-filter` or `--annotation-filter`](#reducing-the-informer-scope) to further limit which objects are cached. **Source configuration** - [ ] Only configure [`--source=`](#source-configuration-and-preflight-validation) types whose CRDs are fully installed and established on the cluster. A missing CRD does not always produce a clear error — it can manifest as a `context deadline exceeded` timeout or silent informer staleness. - [ ] Grant RBAC `list` **and** `watch` for every resource type each configured source requires. A missing `watch` permission lets external-dns start cleanly but freezes its view of the cluster — DNS records drift silently with no crash and no log warning. - [ ] Scope RBAC to only the sources that are configured. Excess permissions hide misconfiguration rather than surfacing it. - [ ] In multi-cluster deployments, use per-cluster source lists rather than a shared configuration. - [ ] Validate against a staging environment that mirrors production CRD and RBAC profiles before rolling out changes. **Scaling** - [ ] Scope resources at every level — service type, label, annotation, domain, zone ID. See [Scope resources](#scope-resources). - [ ] Split into multiple instances for large zone sets or source mixes, each with a distinct --txt-owner-id` and non-overlapping domain scope. See [Split instances](#split-instances). - [ ] Tune reconcile frequency and raise `--request-timeout` on large clusters. See [Reduce reconcile pressure](#reduce-reconcile-pressure). **Observability** - [ ] Alert on [`external_dns_controller_consecutive_soft_errors`](#key-metrics) greater than 0 for more than one reconcile cycle. - [ ] Alert on a sustained increase in [`external_dns_source_errors_total`](#key-metrics) or [`external_dns_registry_errors_total`](#key-metrics). - [ ] Enable [`--events-emit=RecordError`](#kubernetes-events-for-invalid-endpoints) to surface misconfigured endpoints on the responsible Kubernetes resource. **Registry and ownership** - [ ] Set a unique `--txt-owner-id` per external-dns instance and avoid overlapping `--domain-filter` scopes. Multiple instances writing to the same zone without distinct owner IDs can produce conflict errors and, if a conflict causes a hard exit, a crashloop. See [State Conflicts and Ownership](#state-conflicts-and-ownership). **Provider** - [ ] Configure batch change size and interval for your provider if you manage large or frequently-changing zones. See [DNS provider API rate limits](rate-limits.md) for per-provider flags. - [ ] Enable zone caching if your provider supports it. Zone enumeration is an API call on every reconcile; caching it reduces provider API pressure significantly for stable zone sets. See [Zone caching](#zone-list-caching) for supported providers and flags. - [ ] Scope provider credentials (API keys, IAM roles) to only the zones external-dns manages. Zone filtering flags express intent but are not an enforcement boundary — the credentials are. See [Scope provider credentials to specific zones](#scope-provider-credentials-to-specific-zones). --- ## Resource Scope and Memory ### The service source watches more than just Services By default the `service` source registers Kubernetes informers for **Services, Pods, EndpointSlices, and Nodes**. Which informers are active depends on the service types in scope: | Active informers | Triggered when | |:----------------------|:------------------------------------------------| | Services | Always | | Pods + EndpointSlices | `NodePort` or `ClusterIP` services are in scope | | Nodes | `NodePort` services are in scope | With no `--service-type-filter` set (the default), all service types are in scope and all four informers are started. On large clusters this has two consequences: 1. **Steady-state memory**: external-dns holds an in-memory cache of every Pod, EndpointSlice, and Node in the cluster — not only the ones relevant to DNS. 2. **Startup memory burst**: the classic Kubernetes LIST code path fetches all objects of a type into memory at once during initial informer sync. A Pod transformer is applied ([`source/service.go:159`](../../source/service.go)) to reduce stored size, but as the comment there notes: > "If watchList is not used it will not prevent memory bursts on the initial informer sync." ### Reducing the informer scope The most effective mitigation is to restrict the service types external-dns watches: ```sh # Most clusters only need LoadBalancer — eliminates Pod, EndpointSlice, and Node informers entirely --service-type-filter=LoadBalancer ``` Combine with label or annotation filters to further limit the set of Service objects listed: ```sh --label-filter=external-dns/enabled=true --annotation-filter=external-dns.alpha.kubernetes.io/hostname ``` The table below shows which informers are eliminated by `--service-type-filter`: | Filter value | Informers removed | |:----------------------------|:----------------------------------| | `LoadBalancer` | Pods, EndpointSlices, Nodes | | `LoadBalancer,ExternalName` | Pods, EndpointSlices, Nodes | | `ClusterIP` | Nodes | | `NodePort` | *(none — all informers required)* | > **Note:** The informer scope reduction is a side-effect of type filtering, not its primary > purpose. Always choose filters based on what DNS records you actually need to publish; the > memory reduction is a bonus. ### Reducing startup memory bursts The memory burst during initial sync is a known limitation of the classic LIST code path in `client-go`. A streaming alternative called **WatchList** (`WatchListClient` feature gate) avoids the burst by receiving objects one-at-a-time via a Watch with `SendInitialEvents=true` rather than fetching all objects at once. The `WatchListClient` feature gate defaults to `true` in recent versions of client-go, so the burst is effectively eliminated when running the latest release of external-dns. On older releases, `--service-type-filter` is the primary mitigation. **Use the latest release.** > **Note:** Even with WatchList enabled, transformers and indexers on all informer types are > needed to reduce steady-state memory. Work is ongoing to add them consistently across sources. ## Source Configuration and Preflight Validation External-dns fails fast when a configured source cannot initialize — **this is intentional.** A crashloop is a clear, explicit signal that the configuration is wrong. Silently skipping a broken source would mask the problem and make failures much harder to diagnose. Production safety should come from correct configuration and preflight validation, not from external-dns guessing what the user meant. ### Failure modes are not always obvious The difficulty is that RBAC problems and missing CRDs do not always surface as a clean, explicit error. Depending on what is misconfigured, the failure mode can be a timeout, an empty result, or silent staleness rather than a crash: | Misconfiguration | Typical symptom | Why it's subtle | |:---------------------------------|:-----------------------------------------------------------|:------------------------------------------------------------------------------------------------------| | CRD not installed | `context deadline exceeded` after ~60s | Informer blocks waiting for cache sync; no "CRD not found" message | | No LIST permission | `403 Forbidden` → exit | Usually clean, but error may reference an internal API path that is hard to map back to the source | | LIST allowed, WATCH denied | Informer starts, never receives updates | DNS records appear stale; no crash, no error logged after startup | | Admission webhook misconfigured | Source initializes successfully, changes silently rejected | External-dns sees no error; records are never created or updated | The LIST-without-WATCH case is particularly dangerous: external-dns starts cleanly, reports healthy, but its view of the cluster is frozen at the point of last successful LIST. DNS records will drift from actual cluster state without any indication in logs or metrics. ### Practices **Explicitly scope enabled sources.** Only configure `--source=` types that are fully supported on the target cluster — the right CRDs installed, RBAC granted, and any admission webhooks configured. Do not rely on any form of best-effort or graceful degradation. ```sh # Configure only what is present and authorized on this cluster --source=service --source=ingress ``` **Install CRDs before enabling dependent sources.** For sources that depend on custom resources (Gateway API, Istio, CRD source), install the CRDs and verify they are established (`kubectl get crd ` shows `ESTABLISHED`) before adding the corresponding `--source=` flag. A missing CRD does not always produce a "not found" error — it can cause the informer cache sync to block and time out, surfacing as a generic `context deadline exceeded` at startup with no indication of which CRD is missing. **Use per-cluster source lists in multi-cluster deployments.** When managing clusters with different CRD profiles via Helm or ArgoCD, define source lists per cluster rather than sharing a single configuration. A value that works on a cluster with Gateway API installed will crash-loop on one without it. ```yaml # values-cluster-a.yaml (Gateway API installed) sources: - service - gateway-httproute # values-cluster-b.yaml (no Gateway API) sources: - service - ingress ``` **Validate configuration early — fail in CI, not in production.** Add a startup check to your CI or pre-deployment pipeline using `--dry-run --once` against a staging cluster that mirrors the production CRD and RBAC profile. `--once` alone will apply real DNS changes; always pair it with `--dry-run` for validation. A crash in staging is cheap; a crashloop in production affects DNS for all managed records until the pod restarts. **Use minimal RBAC.** Grant external-dns only the API access it needs for the configured sources. Excess permissions are a security concern: if a source is accidentally added to the configuration, external-dns will silently start watching resources it was never intended to manage. Insufficient permissions for a configured source cause a crash on startup, which is the intended signal — but only if RBAC is scoped tightly enough to surface it. ## Scaling on Large Clusters Scaling external-dns comes down to three principles applied in combination: ### Scope resources The fewer Kubernetes objects external-dns watches and the fewer DNS zones it manages, the lower its steady-state memory, API call volume, and reconcile duration. Apply filters at every available level — service type, label, annotation, domain, and zone ID. See [Resource Scope and Memory](#resource-scope-and-memory) and [Domain Filter](domain-filter.md) for details. ### Split instances A single external-dns instance managing a large number of zones or sources will have a large reconcile surface and long reconcile cycles. Splitting into multiple instances — each responsible for a distinct zone set, namespace, or source type — reduces per-instance load and makes failures smaller in blast radius. Each instance must have a distinct `--txt-owner-id` and non-overlapping `--domain-filter` or `--zone-id-filter` scopes to avoid ownership conflicts. See [State Conflicts and Ownership](#state-conflicts-and-ownership). ### Reduce reconcile pressure Tune reconcile frequency to match your actual change rate rather than running at the default interval. Use event-driven reconciliation to react quickly to real changes while keeping background polling infrequent. Raise `--request-timeout` if informer cache sync exceeds the default on large clusters. For per-provider flags covering batch change sizing, record caching, and zone list caching, see [DNS provider API rate limits](rate-limits.md) and [Provider Notes](#provider-notes). ## Observability ### Key metrics The following metrics are the first place to look when diagnosing operational problems: | Metric | When to alert | |:---------------------------------------------------|:--------------------------------------------------------------------| | `external_dns_controller_consecutive_soft_errors` | > 0 for more than one reconcile cycle | | `external_dns_source_errors_total` | Sustained increase (Kubernetes API errors from informers) | | `external_dns_registry_errors_total` | Any increase (TXT / DynamoDB registry failures) | | `external_dns_controller_verified_records` | Unexpected drop (records no longer owned by this instance) | See [Available Metrics](../monitoring/metrics.md) for the full list. > **Future:** Work is in progress to add an `external_dns_source_invalid_endpoints` gauge > (partitioned by `record_type` and `source_type`) that resets and refills each reconcile cycle, > making it straightforward to alert on dropped endpoints without log grepping. Until that lands, > watch `external_dns_source_errors_total` and enable `--events-emit=RecordError` (see below). ### Kubernetes Events for invalid endpoints Invalid endpoints — CNAME self-references, malformed MX/SRV records, unsupported alias types — are silently dropped by the dedup layer with only a log warning at default log levels. Without structured observability, the only way to discover them is to grep logs. Enable `RecordError` events to surface invalid endpoints directly on the responsible Kubernetes resource: ```sh --events-emit=RecordError ``` ```sh # Inspect invalid endpoints across the cluster kubectl get events --field-selector reason=RecordError # Or scoped to a specific resource kubectl describe ingress my-ingress ``` See [Kubernetes Events in External-DNS](events.md) for full documentation and the list of supported event types. ## State Conflicts and Ownership External-dns detects desired vs. current state, computes a plan, and applies it — assuming the plan is internally consistent. When a provider returns a conflict error (HTTP 409 or equivalent), it means the current DNS state does not match what external-dns expects. **This is a state problem, not a software bug.** The correctness of annotations and desired state is the operator's responsibility. External-dns cannot auto-correct user-defined configuration: any automated correction risks removing or replacing DNS records that services depend on, making those services unreachable. Instead, external-dns does its best to make these problems visible so operators can fix them deliberately. It has no general conflict-resolution policy: it drops some well-known invalid records (such as CNAME self-references), but does not apply a subset, auto-correct arbitrary conflicts, or attempt partial best-effort behavior. Retrying the same request without changing the input or reconciling the external state will deterministically fail. ```mermaid flowchart TD A[Kubernetes resources] --> B[external-dns computes plan] B --> C{Apply to DNS provider} C -- Success --> D[DNS records updated] D --> A C -- Conflict error --> E[State mismatch detected] E --> F["No auto-correction
auto-fix risks removing records
services depend on"] F --> G[Problem surfaced
via logs / metrics / events] G --> H[Operator fixes state] H --> A ``` **Crashloop amplification.** A hard error that causes external-dns to exit leads to a crashloop: kubelet restarts the pod, informers resync with full LIST calls to the Kubernetes API for every watched resource type (Services, Pods, EndpointSlices, Nodes), and the same conflicting batch is attempted again. Each restart repeats the cycle, progressively increasing LIST traffic against the Kubernetes API server. On large clusters or at high restart frequency this can contribute to Kubernetes API throttling that affects other controllers and workloads — not just external-dns. ```mermaid flowchart LR A[Conflict error
from provider] --> B[Hard exit] B --> C[kubelet restarts pod] C --> D[Informers resync
full LIST calls to
Kubernetes API] D --> E[Same conflicting
batch applied] E --> A D -. "pressure accumulates\non each restart" .-> F[Kubernetes API
throttling] ``` A hard error that kills the process does not increment `external_dns_controller_consecutive_soft_errors` — that metric tracks soft errors only. Monitor pod restarts via `kube_pod_container_status_restarts_total` and alert on crashloop backoff (`CrashLoopBackOff` status) to catch this early. **When you observe conflict errors, fix the state:** - Ensure a single external-dns instance owns each zone or record set. When using the TXT or DynamoDB registry, use a distinct `--txt-owner-id` per instance and avoid overlapping `--domain-filter` scopes. - Remove or update conflicting records in the DNS provider directly. - Review annotations and desired state for invalid record definitions — for example, mixing CNAME with A/AAAA records for the same hostname, or a CNAME that points to itself. - Check for other controllers or automation writing to the same zone. - If the environment is in an inconsistent state during a migration or incident, scale external-dns to zero until the state is reconciled, then scale it back up. > **Visibility:** Work is ongoing to make state problems as visible as possible before they > become incidents. Planned improvements include per-record-type metrics for rejected endpoints > and Kubernetes Events emitted directly on the responsible resource, so operators can alert on > conflicts without grepping logs. If you encounter a conflict or misconfiguration that is not > surfaced by existing metrics or events, please open an issue or submit a PR. ## Provider Notes ### Zone list caching On every reconcile, external-dns calls the provider API to enumerate the zones it is allowed to manage. For accounts with many zones, or providers with strict API rate limits, this enumeration can be a significant source of API traffic even when no DNS records are changing. Several providers support a zone list cache that stores the zone list in memory for a configurable TTL and re-fetches only after it expires. Set the TTL to reflect how often your zone list actually changes — for most deployments zones are added or removed rarely, so a value of `1h` or longer is appropriate. > **Note:** Zone list caching is distinct from record caching (`--provider-cache-time`), which > caches the DNS records within a zone. Both can be used together. See > [DNS provider API rate limits](rate-limits.md) for per-provider flags. ### Scope provider credentials to specific zones Provider API keys or IAM roles should be scoped to only the zones external-dns is expected to manage. Granting access to all zones in an account has two consequences: - **Operational:** external-dns will enumerate and potentially modify every zone the credentials can reach. A misconfigured filter or a missing `--txt-owner-id` can cause unintended changes to zones outside the intended scope. - **Security:** a credential leak exposes every zone in the account, not just the ones external-dns manages. Zone filtering flags express application-level intent and reduce API call volume, but they are not an enforcement boundary — the credentials are. See [Domain Filter](domain-filter.md) for details. ### Batch API For zones with frequent or large change sets, individual per-record API calls can exhaust provider rate limits quickly. Where supported, a batch API significantly reduces call volume. The exact reduction varies by provider, but the general pattern is: | Approach | API calls per sync | |:-----------|:------------------------------------------------| | Individual | Grows linearly with number of records changed | | Batch | Grows with number of batches, not record count | When a batch submission fails (e.g., one record in the batch is misconfigured), providers typically fall back to individual per-record calls for that sync cycle, so a single bad record does not block DNS updates for the rest of the zone. See [DNS provider API rate limits](rate-limits.md) for per-provider batch flags. ## See Also - [Flags reference](../flags.md) — complete flag listing with defaults - [DNS provider API rate limits](rate-limits.md) — batch sizing, provider cache, and rate-limit tuning - [Domain Filter](domain-filter.md) — domain and zone filtering, and the credential boundary distinction - [Kubernetes Events in External-DNS](events.md) — event types, sources, and consumption - [Available Metrics](../monitoring/metrics.md) — full metrics reference ================================================ FILE: docs/advanced/rate-limits.md ================================================ # DNS provider API rate limits considerations ## Introduction By design, external-dns refreshes all the records of a zone using API calls. This refresh may happen peridically and upon any changed object if the flag `--events` is enabled. Depending on the size of the zone and the infrastructure deployment, this may lead to external-dns hitting the DNS provider's rate-limits more easily. In particular, it has been found that with 200k records in an AWS Route53 zone, each refresh triggers around 70 API calls to retrieve all the records, making it more likely to hit the AWS Route53 API rate limits. To prevent this problem from happening, external-dns has implemented a cache to reduce the pressure on the DNS provider APIs. This cache is optional and systematically invalidated when DNS records have been changed in the cluster (new or deleted domains or changed target). ## Trade-offs The major trade-off of this setting relies in the ability to recover from a deleted record on the DNS provider side. As the DNS records are cached in memory, external-dns will not be made aware of the missing records and will hence take a longer time to restore the deleted or modified record on the provider side. This option is enabled using the `--provider-cache-time=15m` command line argument, and turned off when `--provider-cache-time=0m` ## Monitoring You can evaluate the behaviour of the cache thanks to the built-in metrics * `external_dns_provider_cache_records_calls` * The number of calls to the provider cache Records list. * The label `from_cache=true` indicates that the records were retrieved from memory and the DNS provider was not reached * The label `from_cache=false` indicates that the cache was not used and the records were retrieved from the provider * `external_dns_provider_cache_apply_changes_calls` * The number of calls to the provider cache ApplyChanges. * Each ApplyChange systematically invalidates the cache and makes subsequent Records list to be retrieved from the provider without cache. ## Related options This global option is available for all providers and can be used in pair with other global or provider-specific options to fine-tune the behaviour of external-dns to match the specific needs of your deployments, with the goal to reduce the number of API calls to your DNS provider. * Google * `--google-batch-change-interval=1s` When using the Google provider, set the interval between batch changes. ($EXTERNAL_DNS_GOOGLE_BATCH_CHANGE_INTERVAL) * `--google-batch-change-size=1000` When using the Google provider, set the maximum number of changes that will be applied in each batch. * AWS * `--aws-batch-change-interval=1s` When using the AWS provider, set the interval between batch changes. * `--aws-batch-change-size=1000` When using the AWS provider, set the maximum number of changes that will be applied in each batch. * `--aws-batch-change-size-bytes=32000` When using the AWS provider, set the maximum byte size that will be applied in each batch. * `--aws-batch-change-size-values=1000` When using the AWS provider, set the maximum total record values that will be applied in each batch. * `--aws-zones-cache-duration=0s` When using the AWS provider, set the zones list cache TTL (0s to disable). * `--[no-]aws-zone-match-parent` Expand limit possible target by sub-domains * Cloudflare * `--cloudflare-dns-records-per-page=100` When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100) * OVH * `--ovh-api-rate-limit=20` When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20) * Global * `--registry=txt` The registry implementation to use to keep track of DNS record ownership. * Other registry options such as dynamodb can help mitigate rate limits by storing the registry outside of the DNS hosted zone (default: txt, options: txt, noop, dynamodb, aws-sd) * `--txt-cache-interval=0s` The interval between cache synchronizations in duration format (default: disabled) * `--interval=1m0s` The interval between two consecutive synchronizations in duration format (default: 1m) * `--min-event-sync-interval=5s` The minimum interval between two consecutive synchronizations triggered from kubernetes events in duration format (default: 5s) * `--[no-]events` When enabled, in addition to running every interval, the reconciliation loop will get triggered when supported sources change (default: disabled) A general recommendation is to enable `--events` and keep `--min-event-sync-interval` relatively low to have a better responsiveness when records are created or updated inside the cluster. This should represent an acceptable propagation time between the creation of your k8s resources and the time they become registered in your DNS server. On a general manner, the higher the `--provider-cache-time`, the lower the impact on the rate limits, but also, the slower the recovery in case of a deletion. The `--provider-cache-time` value should hence be set to an acceptable time to automatically recover restore deleted records. ✍️ Note that caching is done within the external-dns controller memory. You can invalidate the cache at any point in time by restarting it (for example doing a rolling update). ================================================ FILE: docs/advanced/split-horizon.md ================================================ # Split Horizon DNS Split horizon DNS allows you to serve different DNS responses based on the client's location - internal clients receive private IPs while external clients receive public IPs. External-DNS supports split horizon DNS by running multiple instances with different annotation prefixes. ## Overview By default, all external-dns instances use the same annotation prefix: `external-dns.alpha.kubernetes.io/`. This means all instances process the same annotations. To enable split horizon DNS, you can configure each instance to use a different annotation prefix via the `--annotation-prefix` flag. ## Use Cases - **Internal/External separation**: Internal DNS points to private IPs (ClusterIP), external DNS points to public Load Balancer IPs - **Multiple DNS providers**: Route different services to different DNS providers (e.g., internal to CoreDNS, external to Route53) - **Geographic split**: Different DNS records for different regions ## Configuration ### Basic Split Horizon Setup **Internal DNS Instance:** ```bash external-dns \ --annotation-prefix=internal.company.io/ \ --source=service \ --source=ingress \ --provider=aws \ --aws-zone-type=private \ --domain-filter=internal.company.com \ --txt-owner-id=internal-dns ``` **External DNS Instance:** ```bash external-dns \ --annotation-prefix=external-dns.alpha.kubernetes.io/ \ # default, can be omitted --source=service \ --source=ingress \ --provider=aws \ --aws-zone-type=public \ --domain-filter=company.com \ --txt-owner-id=external-dns ``` ### Service with Both Annotations ```yaml apiVersion: v1 kind: Service metadata: name: myapp annotations: # Internal DNS reads this internal.company.io/hostname: myapp.internal.company.com internal.company.io/ttl: "300" internal.company.io/target: 10.0.1.50 # Private IP # External DNS reads this external-dns.alpha.kubernetes.io/hostname: myapp.company.com external-dns.alpha.kubernetes.io/ttl: "60" # No target = uses LoadBalancer IP automatically spec: type: LoadBalancer clusterIP: 10.0.1.50 ports: - port: 80 targetPort: 8080 selector: app: myapp ``` **Result:** - **Internal DNS** (Route53 Private Zone `internal.company.com`): `myapp.internal.company.com → 10.0.1.50` - **External DNS** (Route53 Public Zone `company.com`): `myapp.company.com → 203.0.113.10` (LoadBalancer IP) ### Helm Chart Configuration You can use the Helm chart to deploy multiple instances: **values-internal.yaml:** ```yaml annotationPrefix: "internal.company.io/" provider: name: aws aws: zoneType: private domainFilters: - internal.company.com txtOwnerId: internal-dns sources: - service - ingress ``` **values-external.yaml:** ```yaml # annotationPrefix defaults to "external-dns.alpha.kubernetes.io/" # can be omitted or set explicitly: # annotationPrefix: "external-dns.alpha.kubernetes.io/" provider: name: aws aws: zoneType: public domainFilters: - company.com txtOwnerId: external-dns sources: - service - ingress ``` **Deploy:** ```bash # Internal instance helm install external-dns-internal external-dns/external-dns \ --namespace external-dns-internal \ --create-namespace \ --values values-internal.yaml # External instance helm install external-dns-external external-dns/external-dns \ --namespace external-dns-external \ --create-namespace \ --values values-external.yaml ``` ## Advanced Examples ### Three-Way Split (Internal / DMZ / External) ```yaml apiVersion: v1 kind: Service metadata: name: api annotations: # Internal (private network only) internal.company.io/hostname: api.internal.company.com internal.company.io/ttl: "300" # DMZ (accessible from office network) dmz.company.io/hostname: api.dmz.company.com dmz.company.io/ttl: "120" # External (public internet) external-dns.alpha.kubernetes.io/hostname: api.company.com external-dns.alpha.kubernetes.io/ttl: "60" external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" spec: type: LoadBalancer # ... ``` **Deploy three instances:** ```bash # Internal --annotation-prefix=internal.company.io/ --provider=aws --aws-zone-type=private # DMZ --annotation-prefix=dmz.company.io/ --provider=aws --aws-zone-type=private # External --annotation-prefix=external-dns.alpha.kubernetes.io/ --provider=cloudflare ``` ### Different Providers Per Instance ```yaml apiVersion: v1 kind: Service metadata: name: webapp annotations: # Route53 for AWS internal aws.company.io/hostname: webapp.aws.company.com aws.company.io/aws-alias: "true" # Cloudflare for public cf.company.io/hostname: webapp.company.com cf.company.io/cloudflare-proxied: "true" spec: type: LoadBalancer # ... ``` **Deploy:** ```bash # AWS instance --annotation-prefix=aws.company.io/ --provider=aws # Cloudflare instance --annotation-prefix=cf.company.io/ --provider=cloudflare ``` ## Important Notes 1. **Annotation prefix must end with `/`** - The validation will fail if the prefix doesn't end with a forward slash. 2. **Backward compatibility** - If you don't specify `--annotation-prefix`, the default `external-dns.alpha.kubernetes.io/` is used, maintaining full backward compatibility. 3. **All annotations use the same prefix** - When you set a custom prefix, ALL external-dns annotations (hostname, ttl, target, cloudflare-proxied, etc.) must use that prefix. 4. **TXT ownership records** - Each instance should have a unique `--txt-owner-id` to avoid conflicts in ownership tracking. 5. **Provider-specific annotations** - Provider-specific annotations (like `cloudflare-proxied`, `aws-alias`) also use the custom prefix: ```yaml custom.io/hostname: example.com custom.io/cloudflare-proxied: "true" # NOT external-dns.alpha.kubernetes.io/cloudflare-proxied ``` ## Troubleshooting ### Both instances processing the same resources **Problem:** Both internal and external instances are creating records for the same service. **Solution:** Make sure you're using different annotation prefixes and that your services have the correct annotations: ```yaml # ✅ Correct - different prefixes internal.company.io/hostname: internal.example.com external-dns.alpha.kubernetes.io/hostname: example.com # ❌ Wrong - same prefix external-dns.alpha.kubernetes.io/hostname: internal.example.com external-dns.alpha.kubernetes.io/hostname: example.com # Second one overwrites first ``` ### Validation error: "annotation-prefix must end with '/'" **Problem:** The annotation prefix doesn't end with a forward slash. **Solution:** Always end your custom prefix with `/`: ```bash # ✅ Correct --annotation-prefix=custom.io/ # ❌ Wrong --annotation-prefix=custom.io ``` ### Provider-specific annotations not working **Problem:** Cloudflare/AWS-specific annotations are not being applied. **Solution:** Provider-specific annotations must use the same prefix as the hostname: ```yaml # If using custom prefix custom.io/hostname: example.com custom.io/cloudflare-proxied: "true" custom.io/ttl: "60" ``` ## See Also - [Configuration Precedence](configuration-precedence.md) - Understanding how external-dns processes configuration - [FAQ](../faq.md) - Frequently asked questions - [AWS Provider](../tutorials/aws.md) - AWS Route53 configuration - [Cloudflare Provider](../tutorials/cloudflare.md) - Cloudflare configuration ================================================ FILE: docs/advanced/ttl.md ================================================ # Configure DNS record TTL (Time-To-Live) > To customize DNS record TTL (Time-To-Live) in a DNS record`, you can use the `external-dns.alpha.kubernetes.io/ttl: ` annotation or flag `--min-ttl=`. TTL is specified as an integer encoded as string representing seconds. Example; `1s`, `1m2s`, `1h2m11s` Behaviour: - If the `external-dns.alpha.kubernetes.io/ttl` annotation is set, it overrides the default TTL(0) value. - If the annotation is not set, the default TTL value is used, unless the `--min-ttl` flag is provided. - If the annotation is set to `0`, and the `--min-ttl=1s` flag is provided, the value from `--min-ttl` will be used instead. - Not all providers support the custom TTL value, and some may override it with their own default values. To configure it, annotate a service/ingress, e.g.: ```yaml apiVersion: v1 kind: Service metadata: annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com. external-dns.alpha.kubernetes.io/ttl: "60" ... ``` TTL can also be specified as a duration value parsable by Golang [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration): ```yaml apiVersion: v1 kind: Service metadata: annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com. external-dns.alpha.kubernetes.io/ttl: "1m" ... ``` Both examples result in the same value of 60 seconds TTL. TTL must be a positive value. ## TTL annotation support > Note: For TTL annotations to work, the `external-dns.alpha.kubernetes.io/hostname` annotation must be set on the resource and be supported by the provider as well as the source. ### Providers | Provider | Supported | |:---------------|:---------:| | `Akamai` | Yes | | `AlibabaCloud` | Yes | | `AWS` | Yes | | `AWSSD` | Yes | | `Azure` | Yes | | `Civo` | No | | `Cloudflare` | Yes | | `CoreDNS` | No | | `DNSSimple` | Yes | | `Exoscale` | Yes | | `Gandi` | Yes | | `GoDaddy` | Yes | | `Google GCP` | Yes | | `InMemory` | No | | `Linode` | No | | `NS1` | No | | `OCI` | Yes | | `OVH` | No | | `PDNS` | No | | `PiHole` | Yes | | `Plural` | No | | `RFC2136` | Yes | | `Scaleway` | Yes | | `Transip` | Yes | | `Webhook` | Yes | ### Sources | Source | Supported | |:-----------------------|:---------:| | `ambassador-host` | Yes | | `connector` | No | | `contour-httpproxy` | Yes | | `crd` | No | | `empty` | No | | `f5-transportserver` | Yes | | `f5-virtualserver` | Yes | | `fake` | No | | `gateway-grpcroute` | Yes | | `gateway-httproute` | Yes | | `gateway-tcproute` | Yes | | `gateway-tlsroute` | Yes | | `gateway-udproute` | Yes | | `gloo-proxy` | Yes | | `ingress` | Yes | | `istio-gateway` | Yes | | `istio-virtualservice` | Yes | | `kong-tcpingress` | Yes | | `node` | Yes | | `openshift-route` | Yes | | `pod` | Yes | | `service` | Yes | | `skipper-routegroup` | Yes | | `traefik-proxy` | Yes | ## Notes When the `external-dns.alpha.kubernetes.io/ttl` annotation is not provided, the TTL will default to 0 seconds and `endpoint.TTL.isConfigured()` will be false. ### AWS Provider The AWS Provider overrides the value to 300s when the TTL is 0. This value is a constant in the provider code. ### Azure TTL value should be between 1 and 2,147,483,647 seconds. By default it will be 300s. ### CloudFlare Provider CloudFlare overrides the value to "auto" when the TTL is 0. ### DNSimple Provider The DNSimple Provider default TTL is used when the TTL is 0. The default TTL is 3600s. ### Google Provider Previously with the Google Provider, TTL's were hard-coded to 300s. For safety, the Google Provider overrides the value to 300s when the TTL is 0. This value is a constant in the provider code. For the moment, it is impossible to use a TTL value of 0 with the AWS or Google Providers. This behavior may change in the future. ### Linode Provider The Linode Provider default TTL is used when the TTL is 0. The default is 24 hours ### TransIP Provider The TransIP Provider minimal TTL is used when the TTL is 0. The minimal TTL is 60s. ## Use Cases for `external-dns.alpha.kubernetes.io/ttl` annotation and `--min-ttl` flag` The `external-dns.alpha.kubernetes.io/ttl` annotation allows you to set a custom **TTL (Time To Live)** for DNS records managed by `external-dns`. Use the `external-dns.alpha.kubernetes.io/tt` annotation to fine-tune DNS caching behavior per record, balancing between update frequency and performance. This is useful in several real-world scenarios depending on how frequently DNS records are expected to change. --- ### Fast Failover for Critical Services For services that must be highly available—like APIs, databases, or external load balancers—set a **low TTL** (e.g., 30 seconds) so DNS clients quickly update to new IPs during: - Node failures - Region failovers - Blue/green deployments ```yaml annotations: external-dns.alpha.kubernetes.io/ttl: "30s" ``` --- ### Long TTL for Static Services If your service’s IP or endpoint rarely changes (e.g., static websites, internal dashboards), you can set a long TTL (e.g., 86400 seconds = 24 hours) to: - Reduce DNS query load - Improve cache performance - Lower cost with some DNS providers ```yml annotations: external-dns.alpha.kubernetes.io/ttl: "24h" ``` --- ### Canary or Experimental Services Use a short TTL for services under test or experimentation to allow fast DNS propagation when making changes, allowing easy rollback and testing. --- ### Provider-Specific Optimization Some DNS providers charge per query or have query rate limits. Adjusting the TTL lets you: - Reduce costs - Avoid throttling - Manage DNS traffic load efficiently --- ### Regulatory or Contractual SLAs Certain environments may require TTL values to align with: - Regulatory guidelines - Legacy system compatibility - Contractual service-level agreements --- ### Autoscaling Node Pools in GCP (or Other Cloud Providers) In environments like Google Cloud Platform (GCP) using private node IPs for DNS resolution, ExternalDNS may register node IPs with a default TTL of 300 seconds. During autoscaling events (e.g., node addition/removal or upgrades), DNS records may remain stale for several minutes, causing traffic to be routed to non-existent nodes. By using the TTL annotation you can: - Reduce TTL to allow faster DNS propagation - Ensure quicker routing updates when node IPs change - Improve resiliency during frequent cluster topology changes - Fine-grained TTL control helps avoid downtime or misrouting in dynamic, autoscaling environments. ================================================ FILE: docs/annotations/annotations.md ================================================ # Annotations ExternalDNS sources support a number of annotations on the Kubernetes resources that they examine. The following table documents which sources support which annotations: | Source | controller | hostname | internal-hostname | target | ttl | (provider-specific) | |--------------|------------|----------|-------------------|---------|---------|---------------------| | Ambassador | | | | Yes | Yes | Yes | | Connector | | | | | | | | Contour | Yes | Yes[^1] | | Yes | Yes | Yes | | CRD | | | | | | | | F5 | | | | Yes | Yes | | | Gateway | Yes | Yes[^1] | | Yes[^4] | Yes | Yes | | Gloo | | | | Yes | Yes[^5] | Yes[^5] | | Ingress | Yes | Yes[^1] | | Yes | Yes | Yes | | Istio | Yes | Yes[^1] | | Yes | Yes | Yes | | Kong | | Yes[^1] | | Yes | Yes | Yes | | Node | Yes | | | Yes | Yes | | | OpenShift | Yes | Yes[^1] | | Yes | Yes | Yes | | Pod | | Yes | Yes | Yes | | | | Service | Yes | Yes[^1] | Yes[^1][^2] | Yes[^3] | Yes | Yes | | Skipper | Yes | Yes[^1] | | Yes | Yes | Yes | | Traefik | | Yes[^1] | | Yes | Yes | Yes | [^1]: Unless the `--ignore-hostname-annotation` flag is specified. [^2]: Only behaves differently than `hostname` for `Service`s of type `ClusterIP` or `LoadBalancer`. [^3]: Also supported on `Pods` referenced from a headless `Service`'s `Endpoints`. [^4]: For Gateway API sources, annotation placement differs by type. See [Gateway API Annotation Placement](#gateway-api-annotation-placement) for details. [^5]: The annotation must be on the listener's `VirtualService`. ## external-dns.alpha.kubernetes.io/access Specifies which set of node IP addresses to use for a `Service` of type `NodePort`. If the value is `public`, use the Nodes' addresses of type `ExternalIP`, plus IPv6 addresses of type `InternalIP`. If the value is `private`, use the Nodes' addresses of type `InternalIP`. If the annotation is not present and there is at least one address of type `ExternalIP`, behave as if the value were `public`, otherwise behave as if the value were `private`. ## external-dns.alpha.kubernetes.io/controller If this annotation exists and has a value other than `dns-controller` then the source ignores the resource. ## external-dns.alpha.kubernetes.io/endpoints-type Specifies which set of addresses to use for a [`headless Service`](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services). Supported values: - `NodeExternalIP`. Required `--service-type-filter=ClusterIP` and `--service-type-filter=Node` or no `--service-type-filter` flag specified. - `HostIP`. If the value is `NodeExternalIP`, use each relevant `Pod`'s `Node`'s address of type `ExternalIP` plus each IPv6 address of type `InternalIP`. Otherwise, if the value is `HostIP` or the `--publish-host-ip` flag is specified, use each relevant `Pod`'s `Status.HostIP`. Otherwise, use the `IP` of each of the `Service`'s `Endpoints`'s `Addresses`. ## external-dns.alpha.kubernetes.io/hostname Specifies additional domains for the resource's DNS records. Multiple hostnames can be specified through a comma-separated list, e.g. `svc.mydomain1.com,svc.mydomain2.com`. For `Pods`, uses the `Pod`'s `Status.PodIP`, unless they are `hostNetwork: true` in which case the NodeExternalIP is used for IPv4 and NodeInternalIP for IPv6. Notes: - This annotation can override or add extra hostnames alongside any automatically derived hostnames (e.g., from Ingress.spec.rules[].host). - The [`ingress-hostname-source`](#external-dnsalphakubernetesioingress-hostname-source) annotation may be used to specify where to get the domain for an `Ingress` resource. - Hostnames must match the domain filter set in ExternalDNS (e.g., --domain-filter=example.com). - This is an alpha annotation — subject to change; newer versions may support alternatives or deprecate it. - This annotation is helpful for: - Services or other resources without native hostname fields. - Explicit overrides or multi-host situations. - Avoiding reliance on auto-detection or heuristics. ### Use Cases for `external-dns.alpha.kubernetes.io/hostname` annotation #### Explicit Hostname Mapping for Services You have a Service (e.g. of type LoadBalancer or ClusterIP) and want to expose it under a custom DNS name: ```yml apiVersion: v1 kind: Service metadata: name: my-service annotations: external-dns.alpha.kubernetes.io/hostname: app.example.com spec: type: LoadBalancer ... ``` > ExternalDNS will create a A or CNAME record for app.example.com pointing to the external IP or hostname of the service. #### Multi-Hostname Records You can assign multiple hostnames by separating them with commas: ```yml annotations: external-dns.alpha.kubernetes.io/hostname: api.example.com,api.internal.example.com ``` > ExternalDNS will create two DNS records for the same service. #### Static DNS Assignment Without Ingress Rules When using Ingress, you usually declare hostnames in the spec.rules[].host. But with this annotation, you can manage DNS independently: ```yml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-ingress annotations: external-dns.alpha.kubernetes.io/hostname: www.example.com spec: rules: - http: paths: - path: / pathType: Prefix backend: service: name: my-service port: number: 80 ``` > Useful when DNS management is decoupled from routing logic. ## external-dns.alpha.kubernetes.io/ingress-hostname-source Specifies where to get the domain for an `Ingress` resource. If the value is `defined-hosts-only`, use only the domains from the `Ingress` spec. If the value is `annotation-only`, use only the domains from the `Ingress` annotations. If the annotation is not present, use the domains from both the spec and annotations. ## external-dns.alpha.kubernetes.io/ingress This annotation allows ExternalDNS to work with Istio & GlooEdge Gateways that don't have a public IP. It can be used to address a specific architectural pattern, when a Kubernetes Ingress directs all public traffic to an Istio or GlooEdge Gateway: - **The Challenge**: By default, ExternalDNS sources the public IP address for a DNS record from a Service of type LoadBalancer. However, in some setups, the Gateway's Service is of type ClusterIP, with all public traffic routed to it via a separate Kubernetes Ingress object. This setup leaves the Gateway without a public IP that ExternalDNS can discover. - **The Solution**: The annotation on the Istio/GlooEdge Gateway tells ExternalDNS to ignore the Gateway's Service IP. Instead, it directs ExternalDNS to a specified Ingress resource to find the target LoadBalancer IP address. ### Use Cases for `external-dns.alpha.kubernetes.io/ingress` annotation #### Getting target from Ingress backed Gloo Gateway ```yml apiVersion: gateway.solo.io/v1 kind: Gateway metadata: annotations: external-dns.alpha.kubernetes.io/ingress: gateway-proxy labels: app: gloo name: gateway-proxy namespace: gloo-system spec: bindAddress: '::' bindPort: 8080 options: {} proxyNames: - gateway-proxy ssl: false useProxyProto: false --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: gateway-proxy namespace: gloo-system spec: ingressClassName: alb rules: - host: cool-service.example.com http: paths: - backend: service: name: gateway-proxy port: name: http path: / pathType: Prefix status: loadBalancer: ingress: - hostname: k8s-alb-c4aa37c880-740590208.us-east-1.elb.amazonaws.com --- # This object is generated by GlooEdge Control Plane from Gateway and VirtualService. # We have no direct control on this resource apiVersion: gloo.solo.io/v1 kind: Proxy metadata: labels: created_by: gloo-gateway name: gateway-proxy namespace: gloo-system spec: listeners: - bindAddress: '::' bindPort: 8080 httpListener: virtualHosts: - domains: - cool-service.example.com metadataStatic: sources: - observedGeneration: "6652" resourceKind: '*v1.VirtualService' resourceRef: name: cool-service namespace: gloo-system name: cool-service routes: - matchers: - prefix: / metadataStatic: sources: - observedGeneration: "6652" resourceKind: '*v1.VirtualService' resourceRef: name: cool-service namespace: gloo-system upgrades: - websocket: {} metadataStatic: sources: - observedGeneration: "6111" resourceKind: '*v1.Gateway' resourceRef: name: gateway-proxy namespace: gloo-system name: listener-::-8080 useProxyProto: false ``` ## external-dns.alpha.kubernetes.io/internal-hostname Specifies the domain for the resource's DNS records that are for use from internal networks. For `Services` of type `LoadBalancer`, uses the `Service`'s `ClusterIP`. For `Pods`, uses the `Pod`'s `Status.PodIP`. ### Use Cases for `external-dns.alpha.kubernetes.io/internal-hostname` annotation #### Internal DNS Name for a LoadBalancer Service Use this annotation when you want an internal DNS name that resolves to the Service `ClusterIP`, for in-cluster workloads or private network clients. ```yml apiVersion: v1 kind: Service metadata: name: my-service annotations: external-dns.alpha.kubernetes.io/internal-hostname: my-service.internal.example.com spec: type: LoadBalancer ... ``` > ExternalDNS will create an internal DNS record for `my-service.internal.example.com` targeting the Service `ClusterIP`. #### Internal DNS Name for a Pod Use this annotation on a Pod when you want an internal DNS name that resolves to that Pod's `Status.PodIP`. ```yml apiVersion: v1 kind: Pod metadata: name: my-pod annotations: external-dns.alpha.kubernetes.io/internal-hostname: my-pod.internal.example.com spec: ... ``` > ExternalDNS will create an internal DNS record for `my-pod.internal.example.com` targeting the Pod `Status.PodIP`. ## external-dns.alpha.kubernetes.io/target Specifies a comma-separated list of values to override the resource's DNS record targets (RDATA). Targets that parse as IPv4 addresses are published as A records and targets that parse as IPv6 addresses are published as AAAA records. All other targets are published as CNAME records. ## external-dns.alpha.kubernetes.io/ttl Specifies the TTL (time to live) for the resource's DNS records. The value may be specified as either a duration or an integer number of seconds. It must be between `1` and `2,147,483,647` seconds. > Note; setting the value to `0` means, that TTL is not configured and thus use default. ## external-dns.alpha.kubernetes.io/gateway-hostname-source Specifies where to get the domain for a `Route` resource. This annotation should be present on the actual `Route` resource, not the `Gateway` resource itself. If the value is `defined-hosts-only`, use only the domains from the `Route` spec. If the value is `annotation-only`, use only the domains from the `Route` annotations. If the annotation is not present, use the domains from both the spec and annotations. ## Provider-specific annotations Some providers define their own annotations. Cloud-specific annotations have keys prefixed as follows: | Cloud | Annotation prefix | |------------|------------------------------------------------| | AWS | `external-dns.alpha.kubernetes.io/aws-` | | CloudFlare | `external-dns.alpha.kubernetes.io/cloudflare-` | | Scaleway | `external-dns.alpha.kubernetes.io/scw-` | Additional annotations implemented by specific providers: ### external-dns.alpha.kubernetes.io/alias If the value of this annotation is `true`, specifies that CNAME records generated by the resource should instead be alias records. This annotation is only supported on A, AAAA, and CNAME record types. Endpoints with other record types (e.g. MX, SRV, TXT) that have this annotation set will be rejected. **Supported providers:** - **AWS**: This annotation is only relevant if the `--aws-prefer-cname` flag is specified. - **PowerDNS**: When this annotation is set to `true`, CNAME records will be created as ALIAS records. This is useful when using PowerDNS with `expand-alias=yes` to resolve CNAME targets to IP addresses on the authoritative server side. Alternatively, use the `--prefer-alias` flag to convert all CNAME records to ALIAS globally. ### external-dns.alpha.kubernetes.io/set-identifier Specifies the set identifier for DNS records generated by the resource. A set identifier differentiates among multiple DNS record sets that have the same combination of domain and type. Which record set or sets are returned to queries is then determined by the configured routing policy. Required for AWS Route53 routing policies (weighted, latency, failover, geolocation, geoproximity, multi-value). See the [AWS tutorial — Routing policies](../tutorials/aws.md#routing-policies) for the full list of annotations and examples. Notes: - The annotation is provider-agnostic in design but is primarily used with AWS Route53 routing policies. - The value is arbitrary but must be **unique per record set** for the same domain and type combination. - For Gateway API sources, this annotation must be placed on **Route resources** (e.g., `HTTPRoute`), not on the `Gateway` resource itself. See [Gateway API Annotation Placement](#gateway-api-annotation-placement). ### Gateway API with HTTPRoute When using Gateway API, place `set-identifier` on the Route resource, not the Gateway: ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: my-gateway annotations: # target goes on the Gateway external-dns.alpha.kubernetes.io/target: "alb-123.us-east-1.elb.amazonaws.com" --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: my-route annotations: # set-identifier and routing policy go on the Route external-dns.alpha.kubernetes.io/set-identifier: backend-v1 external-dns.alpha.kubernetes.io/aws-weight: "100" spec: parentRefs: - name: my-gateway hostnames: - app.example.com ``` > Placing `set-identifier` on the Gateway instead of the Route is a common mistake — the Gateway source only reads the `target` annotation. ## Gateway API Annotation Placement When using Gateway API sources (`gateway-httproute`, `gateway-grpcroute`, `gateway-tlsroute`, etc.), annotations are read from different resources: **Gateway resource** reads only `target` annotation, while **Route resources** (HTTPRoute, GRPCRoute, TLSRoute, etc.) read all other annotations (`hostname`, `ttl`, `controller`, and provider-specific annotations like `cloudflare-*`, `aws-*`, `scw-*`). For more details and comprehensive examples, see the [Gateway API documentation](../sources/gateway-api.md#annotations). ================================================ FILE: docs/contributing/bug-report.md ================================================ # Bug Report Guide > **Before filing a bug:** validate the behavior against the [latest release](https://github.com/kubernetes-sigs/external-dns/releases). > We do not support past versions. > > [!WARNING] > > The outputs requested in this guide may contain sensitive information such as > domain names, IP addresses, cloud account IDs, annotation values, or > credentials. Redact any sensitive values before posting them publicly in a > GitHub issue. Bug reports regularly arrive without the information needed to reproduce or debug them — no process flags, no normalized Kubernetes resources, no logs — forcing maintainers to ask multiple follow-up rounds before any investigation can start. A bug that cannot be reproduced will not be fixed. This page explains exactly what information to collect and how to collect it so that maintainers can reason about your environment without making assumptions. --- ## Why we need normalized resources external-dns only reads Kubernetes API objects at runtime. It does not read Helm values, Terraform state, Flux kustomizations, or AWS Load Balancer Controller annotations directly — it sees only what those tools produce in the API server. **Please provide the live Kubernetes objects**, not the templates that generated them. --- ## Reproduce on a local cluster If your environment is not reproducible or involves proprietary infrastructure, the fastest path to a fix is reproducing the issue on a local cluster: [minikube](https://minikube.sigs.k8s.io) or [kind](https://kind.sigs.k8s.io). --- ## Step-by-step: collect the required information Work through each section below and paste the output into your issue. ### 1 — external-dns info **Version** ```sh kubectl get pod -n -l app.kubernetes.io/name=external-dns \ -o jsonpath='{.items[0].spec.containers[0].image}' ``` Or, if you have direct access to the binary: ```sh external-dns --version ``` **Startup flags** Helm values and Terraform variables are *not* useful here because they are transformed before reaching the process. We need the flags that the external-dns **process** was actually started with. ```sh kubectl get pod -n \ -o jsonpath='{range .spec.containers[*]}{.args}{end}' ``` Example of the kind of output we need: ```text --provider=aws --registry=txt --txt-owner-id=my-cluster --source=ingress --domain-filter=example.com --log-level=debug ``` **Debug logs** Enable debug logging before reproducing the issue. If external-dns is already deployed, patch it: ```sh kubectl set env deployment/external-dns \ -n \ EXTERNAL_DNS_LOG_LEVEL=debug ``` Or add `--log-level=debug` to the process args and redeploy. Once the pod restarts, collect logs **covering the full reconciliation loop** that should have created or updated the record: ```sh kubectl logs -n \ -l app.kubernetes.io/name=external-dns \ --since=10m \ --prefix=true \ > extdns-debug.log ``` Paste the full content of `extdns-debug.log` into the issue (or attach the file). Specifically we look for lines like: ```text level=debug msg="Desired change: CREATE example.com A [1.2.3.4]" level=debug msg="No endpoints could be generated from ingress ..." level=info msg="All records are already up to date" ``` ### 2 — Kubernetes resources Collect the full YAML — including `status` — for every resource relevant to your source type. If reporting a regression, include the output **before and after** the change. The `status.loadBalancer` field is critical for ingress and service sources. ```sh kubectl get -A -o yaml ``` Common examples by source: ```sh kubectl get ingress,service -A -o yaml # source=ingress kubectl get service -A -o yaml # source=service kubectl get gateway,httproute -A -o yaml # source=gateway-httproute kubectl get dnsendpoint -A -o yaml # source=crd kubectl get nodes -o yaml # source=node ``` ### 3 — DNS provider: existing vs expected records Tell us what records **actually exist** in your DNS provider and what you **expected** to exist. For Route 53: ```sh aws route53 list-resource-record-sets \ --hosted-zone-id \ --query 'ResourceRecordSets[?Name==`example.com.`]' ``` For other providers, use their CLI or API equivalent, or paste a screenshot from the console. Format the answer as: | Record | Type | Value | TTL | Expected? | |-------------------|-------|-------------------------------|-----|-----------| | `foo.example.com` | `A` | `1.2.3.4` | 300 | yes | | `foo.example.com` | `TXT` | `"heritage=external-dns,..."` | 300 | yes | ### 4 — TXT ownership records external-dns uses TXT records to track ownership. If records are not being created or are being deleted unexpectedly, include the TXT records: ```sh # Route 53 example — look for TXT records with "heritage=external-dns" aws route53 list-resource-record-sets \ --hosted-zone-id \ --query 'ResourceRecordSets[?Type==`TXT`]' ``` --- ## Collection scripts **external-dns info** — version, startup args, and logs: ```sh [[% include 'snippets/contributing/collect-extdns-info.sh' %]] ``` **Kubernetes resources** — set `RESOURCE` to the resource(s) relevant to your source (e.g. `ingress`, `"ingress,service"`, `"gateway,httproute"`, `dnsendpoint`): ```sh [[% include 'snippets/contributing/collect-resources.sh' %]] ``` --- ## Checklist before submitting - [ ] I have searched existing issues and tried to find a fix myself - [ ] I am using the [latest release](https://github.com/kubernetes-sigs/external-dns/releases), or have checked the [staging image](../release.md#staging-release-cycle) to confirm the bug is still reproducible - [ ] I have provided the actual process flags (not Helm values) - [ ] I have provided `kubectl get -o yaml` output (with `status`) - [ ] I have provided external-dns debug logs - [ ] I have described what DNS records exist and what I expected --- ## Notes on third-party controllers If you are using **AWS Load Balancer Controller**, **Flux**, **Terraform**, or similar tools alongside external-dns, note that multiple controllers may be reading and modifying the same Kubernetes objects at runtime. external-dns maintainers can only reason about what external-dns *sees* in the API server — please provide normalized Kubernetes objects as described above, rather than the configuration of the surrounding tooling. Contributors and maintainers are very unlikely to be running the same stack. Bug reporters should assume zero shared context — no cluster access, no cloud account, no Helm values, and no knowledge of any third-party controllers in use. A well-detailed report — see the [checklist above](#checklist-before-submitting) — minimizes guesswork and significantly increases the chance of resolution. ================================================ FILE: docs/contributing/chart.md ================================================ # Helm Chart ## Chart Changes When contributing chart changes please follow the same process as when contributing other content but also please **DON'T** modify _Chart.yaml_ in the PR as this would result in a chart release when merged and will mean that your PR will need modifying before it can be accepted. The chart version will be updated as part of the PR to release the chart. Please **DO** add your changes to the _CHANGELOG.md_ file in the chart directory under the `## [UNRELEASED]` section, if there isn't an uncommented `## [UNRELEASED]` section please copy the commented out template and use that. ================================================ FILE: docs/contributing/design.md ================================================ # Design ExternalDNS's sources of DNS records live in package [source](https://github.com/kubernetes-sigs/external-dns/tree/master/source). They implement the `Source` interface that has a single method `Endpoints` which returns the represented source's objects converted to `Endpoints`. Endpoints are just a tuple of DNS name and target where target can be an IP or another hostname. For example, the `ServiceSource` returns all Services converted to `Endpoints` where the hostname is the value of the `external-dns.alpha.kubernetes.io/hostname` annotation and the target is the IP of the load balancer or the target is the IP of the service ClusterIP. This list of endpoints is passed to the [Plan](https://github.com/kubernetes-sigs/external-dns/tree/master/plan) which determines the difference between the current DNS records and the desired list of `Endpoints`. Once the difference has been figured out the list of intended changes is passed to a `Registry` which live in the [registry](https://github.com/kubernetes-sigs/external-dns/tree/master/registry) package. The registry is a wrapper and access point to DNS provider. Registry implements the ownership concept by marking owned records and filtering out records not owned by ExternalDNS before passing them to DNS provider. The [provider](https://github.com/kubernetes-sigs/external-dns/tree/master/provider) is the adapter to the DNS provider, e.g. Google Cloud DNS. It implements two methods: `ApplyChanges` to apply a set of changes filtered by `Registry` and `Records` to retrieve the current list of records from the DNS provider. The orchestration between the different components is controlled by the [controller](https://github.com/kubernetes-sigs/external-dns/tree/master/controller). You can pick which `Source` and `Provider` to use at runtime via the `--source` and `--provider` flags, respectively. ## Adding a DNS Provider A typical way to start on, e.g. a CoreDNS provider, would be to add a `coredns.go` to the providers package and implement the interface methods. Then you would have to register your provider under a name in `main.go`, e.g. `coredns`, and would be able to trigger its functions via setting `--provider=coredns`. Note, how your provider doesn't need to know anything about where the DNS records come from, nor does it have to figure out the difference between the current and the desired state, it merely executes the actions calculated by the plan. ## Running GitHub Actions locally You can also extend the CI workflow which is currently implemented as GitHub Action within the [workflow](https://github.com/kubernetes-sigs/external-dns/tree/HEAD/.github/workflows) folder. In order to test your changes before committing you can leverage [act](https://github.com/nektos/act) to run the GitHub Action locally. Follow the installation instructions in the nektos/act [README.md](https://github.com/nektos/act/blob/master/README.md). Afterwards just run `act` within the root folder of the project. For further usage of `act` refer to its documentation. ================================================ FILE: docs/contributing/dev-guide.md ================================================ --- tags: - contributing - build - testing - integration-tests - helm --- # Developer Reference The `external-dns` is the work of thousands of contributors, and is maintained by a small team within [kubernetes-sigs](https://github.com/kubernetes-sigs). This document covers basic needs to work with `external-dns` codebase. It contains instructions to build, run, and test `external-dns`. ## Tools Building and/or testing `external-dns` requires additional tooling. - [Git](https://git-scm.com/downloads) - [Go 1.25+](https://golang.org/dl/) - [Go modules](https://github.com/golang/go/wiki/Modules) - [golangci-lint](https://github.com/golangci/golangci-lint) - [ko](https://ko.build/) - [kubectl](https://kubernetes.io/docs/tasks/tools/install-kubectl) - [helm](https://helm.sh/docs/helm/helm_install/) - [spectral](https://github.com/stoplightio/spectral) - [python](https://www.python.org/downloads/) ### Go Tools Additional Go-based tools are managed in [`go.tool.mod`](../../go.tool.mod) and used for code generation: | Tool | Purpose | |-----------------------------------------------------------------------|----------------------------------------------------| | [controller-gen](https://github.com/kubernetes-sigs/controller-tools) | Generates CRD manifests and deepcopy methods | | [yq](https://github.com/mikefarah/yq) | YAML processing (splitting, filtering CRD outputs) | | [yamlfmt](https://github.com/google/yamlfmt) | YAML formatting | List all installed Go tools: ```sh make go-tools ``` Update Go tools to their latest versions: ```sh make update-tools-deps ``` > **Note:** Updates are done manually because Dependabot does not yet support `go.tool.mod` > ([dependabot-core#12050](https://github.com/dependabot/dependabot-core/issues/12050)). ## First Steps ***Configure Development Environment*** You must have a working [Go environment](https://go.dev/doc/install), compile the build, and set up testing. ```shell git clone https://github.com/kubernetes-sigs/external-dns.git && cd external-dns ``` ## Building & Testing The project uses the make build system. It'll run code generators, tests and static code analysis. Build, run tests and lint the code: ```shell make go-lint make test make cover-html ``` If added any flags or metrics, re-generate documentation ```shell make generate-flags-documentation make generate-metrics-documentation ``` We require all changes to be covered by acceptance tests and/or unit tests, depending on the situation. In the context of the `external-dns`, acceptance tests are tests of interactions with providers, such as creating, reading information about, and destroying DNS resources. In contrast, unit tests test functionality wholly within the codebase itself, such as function tests. ### Log Unit Testing Testing log messages within codebase provides significant advantages, especially when it comes to debugging, monitoring, and gaining a deeper understanding of system behavior. Log library [build-in testing functionality](https://github.com/sirupsen/logrus?tab=readme-ov-file#testing) This practice enables: - Early detection of logging issues - Verification of Important Information - Ensuring Correct Severity Levels - Improving Observability and Monitoring - Driving Better Logging Practices To illustrate how to unit test log output within functions, consider the following example: ```go import ( "testing" "sigs.k8s.io/external-dns/internal/testutils" ) func TestMe(t *testing.T) { hook := testutils.LogsUnderTestWithLogLevel(log.WarnLevel, t) ... function under tests ... testutils.TestHelperLogContains("example warning message", hook, t) // provide negative assertion testutils.TestHelperLogNotContains("this message should not be shown", hook, t) } ``` ## CRD Generation The `DNSEndpoint` CRD manifest is generated from Go types using `controller-gen` and must be regenerated whenever the types in `endpoint/` or `apis/` change. ```sh make crd ``` This runs [`scripts/generate-crd.sh`](../../scripts/generate-crd.sh) which: 1. Generates `DeepCopy` methods for types in `endpoint/` and `apis/` 2. Generates the CRD manifest into `config/crd/standard/` 3. Copies the CRD (with filtered annotations) into `charts/external-dns/crds/` The `controller-gen.kubebuilder.io/version` annotation in the generated YAML reflects the version of `controller-gen` from `go.tool.mod` at generation time and is updated automatically. ### Integration Tests Integration tests live in `tests/integration/` and verify behavior that spans multiple sources or wrappers together, using a fake Kubernetes client — no real cluster is required. #### Where integration tests sit ```mermaid flowchart TD E2E["E2E Tests
Real cluster + real DNS provider
Slow · requires cloud credentials"] IT["Integration Tests ← tests/integration/
Fake Kubernetes API · no cluster needed
Tests source + wrapper combinations · fast
Declarative YAML scenarios"] UT["Unit Tests
One source or wrapper in isolation
Mocked or minimal Kubernetes client"] E2E --> IT --> UT style IT fill:#bbf7d0,stroke:#15803d,stroke-width:2px ``` #### What runs during a test ```mermaid flowchart LR subgraph yaml["tests/integration/scenarios/tests.yaml"] RES["resources
Service · Ingress · Pod"] CFG["config
sources · filters · wrappers"] EXP["expected
endpoints"] end subgraph toolkit["toolkit — fake Kubernetes"] PARSE["ParseResources()"] FAKE["fake.Clientset"] WRAP["CreateWrappedSource()"] end subgraph pipeline["ExternalDNS pipeline under test"] SRC["Source(s)
service · ingress · ..."] WRP["Wrapper(s)
dedup · targetFilter · NAT64"] OUT["Endpoints"] end ASSERT["ValidateEndpoints()
DNSName · Targets
RecordType · TTL"] RES --> PARSE --> FAKE --> WRAP CFG --> WRAP WRAP --> SRC --> WRP --> OUT --> ASSERT EXP --> ASSERT ``` **When to add an integration test:** - You are adding or changing a **source** (e.g. `service`, `ingress`) and want to verify it produces the correct endpoints end-to-end. - You are changing a **wrapper** (e.g. deduplication, target filtering, default targets, NAT64) and want to verify it behaves correctly when real Kubernetes resources are involved. - You are changing a **post-processor** and want to confirm it applies correctly to endpoints produced by one or more sources. - You are verifying **multiple sources** together (e.g. `service` and `ingress` both pointing to the same hostname) and their combined output. - You are fixing a **cross-cutting bug** that only manifests when sources, wrappers, and post-processors interact. - A unit test would require mocking too many internals — an integration test can express the scenario more clearly as a real Kubernetes resource. **How to add a scenario:** Add an entry to `tests/integration/scenarios/tests.yaml`. Each scenario declares Kubernetes resources (Service, Ingress, etc.), the ExternalDNS source configuration, and the expected endpoints: ```yaml - name: my-new-scenario description: > Brief explanation of what behavior this scenario validates. config: sources: ["service"] resources: - resource: apiVersion: v1 kind: Service metadata: name: my-svc namespace: default annotations: external-dns.alpha.kubernetes.io/hostname: my.example.com spec: type: LoadBalancer status: loadBalancer: ingress: - ip: 1.2.3.4 expected: - dnsName: my.example.com targets: ["1.2.3.4"] recordType: A ``` **How to run:** ```shell go test ./tests/integration/... ``` ## Complete test on local env It's possible to run ExternalDNS locally. CoreDNS can be used for easier testing. See the [related tutorials](../tutorials/coredns-etc.md) for full instructions. ### Continuous Integration When submitting a pull request, you'll notice that we run several automated processes on your proposed change. Some of these processes are tests to ensure your contribution aligns with our standards. While we strive for accuracy, some users may find these tests confusing. ## Execute code without building binary The `external-dns` does not require `make build`. You could compile and run Go program with the command ```sh go run main.go \ --provider=aws \ --registry=txt \ --source=fake \ --log-level=info ``` For this command to run successfully, it will require [AWS credentials](https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-files.html) and access to local or remote access. To run local cluster please refer to [running local cluster](#create-a-local-cluster) ## Deploying a local build After building local images, it is often useful to deploy those images in a local cluster We use [Minikube](https://minikube.sigs.k8s.io/docs/start/?arch=%2Fmacos%2Fx86-64%2Fstable%2Fbinary+download) but it could be [Kind](https://kind.sigs.k8s.io/) or any other solution. - [Create local cluster](#create-a-local-cluster) - [Build and load local images](#building-local-images) - Deploy with Helm - Deploy with kubernetes manifests ## Create a local cluster For simplicity, [minikube](https://minikube.sigs.k8s.io) can be used to create a single node cluster. You can set a specific Kubernetes version by setting the node's container image. See [basic controls](https://minikube.sigs.k8s.io/docs/handbook/controls/) within the documentation about configuration for more details on this. Once you have a configuration in place, create the cluster with that configuration: ```sh minikube start \ --profile=external-dns \ --memory=2000 \ --cpus=2 \ --disk-size=5g \ --kubernetes-version=v1.31 \ --driver=docker minikube profile external-dns ``` After the new Kubernetes cluster is ready, identify the cluster is running as the single node cluster: ```sh ❯❯ kubectl get nodes NAME STATUS ROLES AGE VERSION external-dns Ready control-plane 16s v1.31.4 ``` --- ## Building local images When building local images with ko you can't specify the registry used to create the image names. It will always be ko.local. - [minikube handbooks](https://minikube.sigs.k8s.io/docs/handbook/pushing/) > Note: You could skip this step if you build and push image to your private registry or using an official external-dns image ```sh ❯❯ export KO_DOCKER_REPO=ko.local ❯❯ export VERSION=v1 ❯❯ docker context use rancher-desktop ## (optional) this command is only required when using rancher-desktop ❯❯ ls -al /var/run/docker.sock ## (optional) validate that docker runtime is configured correctly and symlink exists ❯❯ ko build --tags ${VERSION} ❯❯ docker images $$ ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63 local-v1 ``` ***Push image to minikube*** Refer to [load image](https://minikube.sigs.k8s.io/docs/handbook/pushing/#7-loading-directly-to-in-cluster-container-runtime) ```sh ❯❯ minikube image load ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63:local-v1 ❯❯ minikube image ls $$ registry.k8s.io/pause:3.10 $$ ... $$ ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63:local-v1 $$ ... ❯❯ kubectl run external-dns --image=ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63:local-v1 --image-pull-policy=Never ``` ***Build and push directly in minikube*** Any `docker` command you run in this current terminal will run against the docker inside minikube cluster. Refer to [push directly](https://minikube.sigs.k8s.io/docs/handbook/pushing/#1-pushing-directly-to-the-in-cluster-docker-daemon-docker-env) ```sh ❯❯ eval $(minikube -p external-dns docker-env) ❯❯ echo $MINIKUBE_ACTIVE_DOCKERD $$ external-dns ❯❯ export VERSION=v1 ❯❯ ko build --local --tags ${VERSION} ❯❯ docker images $$ REPOSITORY TAG $$ registry.k8s.io/kube-apiserver v1.31.4 $$ .... $$ ko.local/external-dns-9036f6870f30cbdefa42a10f30bada63 minikube-v1 $$ ... ❯❯ eval $(minikube docker-env -u) ## unset minikube ``` ***Pushing to an in-cluster using Registry addon*** Refer to [pushing images](https://minikube.sigs.k8s.io/docs/handbook/pushing/#4-pushing-to-an-in-cluster-using-registry-addon) for a full configuration ```sh ❯❯ export KO_DOCKER_REPO=$(minikube ip):5000 ❯❯ export VERSION=registry-v1 ❯❯ minikube addons enable registry ❯❯ ko build --tags ${VERSION} ``` ## Building image and push to a registry Build container image and push to a specific registry ```shell make build.push IMAGE=your-registry/external-dns ``` --- ## Deploy with Helm Build local images if required, load them on a local cluster, and deploy helm charts, run: Render chart templates locally and display the output ```sh ❯❯ helm lint --debug charts/external-dns ❯❯ helm template external-dns charts/external-dns --output-dir _scratch ``` Deploy manifests to a cluster with required values ```sh ❯❯ kubectl apply -f _scratch --recursive=true ``` Modify chart or values and validate the diff ```sh ❯❯ helm template external-dns charts/external-dns --output-dir _scratch ❯❯ kubectl diff -f _scratch/external-dns --recursive=true --show-managed-fields=false ``` ### Helm Values This helm chart comes with a JSON schema generated from values with [helm schema](https://github.com/losisin/helm-values-schema-json.git) plugin. 1. Install required plugin(s) ```sh ❯❯ scripts/helm-tools.sh --install ``` 2. Ensure that the schema is always up-to-date ```sh ❯❯ scripts/helm-tools.sh --diff ``` 3. When not up-to-date, update JSON schema ```sh ❯❯ scripts/helm-tools.sh --schema ``` 4. Runs a series of tests to verify that the chart is well-formed, linted and JSON schema is valid ```sh ❯❯ scripts/helm-tools.sh --lint ``` 5. Auto-generate documentation for helm charts into markdown files. ```sh ❯❯ scripts/helm-tools.sh --docs ``` 6. Run helm unittests. ```sh ❯❯ make helm-test ``` 7. Add an entry to the chart [CHANGELOG.md](../../charts/external-dns/CHANGELOG.md) under `## UNRELEASED` section and `open` pull request ## Deploy with kubernetes manifests > Note; kubernetes manifest are not up to date. Consider to create an `examples` folder ```sh kubectl apply -f kustomize --recursive=true --dry-run=client ``` ## Contribute to documentation All documentation is in `docs` folder. If new page is added or removed, make sure `mkdocs.yml` is also updated. Install required dependencies. In order to not to break system packages, we are going to use virtual environments with [pipenv](https://pipenv.pypa.io/en/latest/installation.html). ```sh ❯❯ pipenv shell ❯❯ pip install -r docs/scripts/requirements.txt ❯❯ mkdocs serve $$ ... $$ Serving on http://127.0.0.1:8000/ ``` ### How to add an example snippet Let's say we are improving tutorial location in `docs/tutorials/aws.md`. 1. Add a snippet to `docs/snippets/aws/.` 2. Add snippet to a markdown file `docs/tutorials/aws.md` [[% raw %]] ````md ```extension [[% include 'snippets/aws/.' %]] ``` ```` [[% endraw %]] ================================================ FILE: docs/contributing/index.md ================================================ # Developer Documentations (Advanced Topics) This folder contains developer documentation. When you are ready to contribute, you can select issue at [Good First Issues](https://github.com/kubernetes-sigs/external-dns/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22). To get started see: [dev-guide.md](dev-guide.md). > Note; when new feature/fix is ready, consider also to provide a way to test this manually with manifests and kubectl commands ## Submit an Issue In addition to contributions, we welcome [bug reports](https://github.com/kubernetes-sigs/external-dns/issues/new?template=---bug-report.md) and [feature requests](https://github.com/kubernetes-sigs/external-dns/issues/new?template=--enhancement-request.md). When filing a bug report, follow the **[Bug Report Guide](bug-report.md)** to collect the normalized Kubernetes resources, process flags, and logs that maintainers need to reproduce and fix the issue. ================================================ FILE: docs/contributing/source-wrappers.md ================================================ # 🧩 Source Wrappers/Middleware ## Overview In ExternalDNS, a **Source** is a component responsible for discovering DNS records from Kubernetes resources (e.g., `Ingress`, `Service`, `Gateway`, etc.). **Source Wrappers** are middleware-like components that sit between the source and the plan generation. They extend or modify the behavior of the original sources by transforming, filtering, or enriching the DNS records before they're processed by the planner and provider. --- ## Why Wrappers? Wrappers solve these key challenges: - ✂️ **Filtering**: Remove unwanted targets or records from sources based on labels, annotations, targets and etc. - 🔗 **Aggregation**: Combine Endpoints from multiple underlying sources. For example, from both Kubernetes Services and Ingresses. - 🧹 **Deduplication**: Prevent duplicate DNS records across sources. - 🌐 **Target transformation**: Rewrite targets for IPv6 networks or alter endpoint attributes like FQDNS or targets. - 🧪 **Testing and simulation**: Use the `FakeSource` or wrappers for dry-runs or simulations. - 🔁 **Composability**: Chain multiple behaviors without modifying core sources. - 🔐 **Access Control**: Limits endpoint exposure based on policies or user access. - 📊 **Observability**: Adds logging, debugging, or metrics around source behavior. --- ## Built In Wrappers | Wrapper | Purpose | Use Case | |:--------------------:|:----------------------------------------|:----------------------------------------------------| | `MultiSource` | Combine multiple sources. | Aggregate `Ingress`, `Service`, etc. | | `DedupSource` | Remove duplicate DNS records. | Avoid duplicate records from sources. | | `TargetFilterSource` | Include/exclude targets based on CIDRs. | Exclude internal IPs. | | `NAT64Source` | Add NAT64-prefixed AAAA records. | Support IPv6 with NAT64. | | `PostProcessor` | Add records post-processing. | Configure TTL, filter provider-specific properties. | ### Use Cases ### 1.1 `TargetFilterSource` Filters targets (e.g. IPs or hostnames) based on inclusion or exclusion rules. 📌 **Use case**: Only publish public IPs, exclude test environments. ```yaml --target-net-filter=192.168.0.0/16 --exclude-target-nets=10.0.0.0/8 ``` ### 2.1 `NAT64Source` Converts IPv4 targets to IPv6 using NAT64 prefixes. 📌 **Use case**: Publish AAAA records for IPv6-only clients in NAT64 environments. ```yaml --nat64-prefix=64:ff9b::/96 ``` ### 3.1 `PostProcessor` Applies post-processing to all endpoints after they are collected from sources. 📌 **Use case** - Sets a minimum TTL on endpoints that have no TTL or a TTL below the configured minimum. - Filters `ProviderSpecific` properties to retain only those belonging to the configured provider (e.g. `aws/evaluate-target-health` when provider is `aws`). Properties with no provider prefix (e.g. `alias`) are considered provider-agnostic and are always retained. - Sets the `alias=true` provider-specific property on `CNAME` endpoints when `--prefer-alias` is enabled, signalling providers that support ALIAS records (e.g. PowerDNS, AWS) to use them instead of CNAMEs. Per-resource annotations already present are not overwritten. ```yaml --min-ttl=60s --provider=aws --prefer-alias ``` --- ## How Wrappers Work Wrappers wrap a `Source` and implement the same `Source` interface (e.g., `Endpoints(ctx)`). They typically follow this pattern: ```go package wrappers type myWrapper struct { next source.Source } func (m *myWrapper) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { eps, err := m.next.Endpoints(ctx) if err != nil { return nil, err } // Modify, filter, or enrich endpoints as needed return eps, nil } // AddEventHandler must be implemented to satisfy the source.Source interface. func (m *myWrapper) AddEventHandler(ctx context.Context, handler func()) { log.Debugf("myWrapper: adding event handler") m.next.AddEventHandler(ctx, handler) } ``` This allows wrappers to be stacked or composed together. --- ### Composition of Wrappers Wrappers are often composed like this: ```go source := NewMultiSource(actualSources, defaultTargets) source = NewDedupSource(source) source = NewNAT64Source(source, cfg.NAT64Networks) source = NewTargetFilterSource(source, targetFilter) ``` Each wrapper processes the output of the previous one. --- ## High Level Design - Source: Implements the base logic for extracting DNS endpoints (e.g. IngressSource, ServiceSource, etc.) - Wrappers: Decorate the source (e.g. DedupSource, TargetFilterSource) to enhance or filter endpoint data - Plan: Compares the endpoints from Source with DNS state from Provider and produces create/update/delete changes - Provider: Applies changes to actual DNS services (e.g. Route53, Cloudflare, Azure DNS) ```mermaid sequenceDiagram participant ExternalDNS participant Source participant Wrapper participant DedupWrapper as DedupSource participant Provider participant Plan ExternalDNS->>Source: Initialize source (e.g. Ingress, Service) Source-->>ExternalDNS: Implements Source interface ExternalDNS->>Wrapper: Wrap with decorators (e.g. dedup, filters) Wrapper->>DedupWrapper: Compose with DedupSource DedupWrapper-->>Wrapper: Return enriched Source Wrapper-->>ExternalDNS: Return final wrapped Source ExternalDNS->>Plan: Generate plan from Source Plan->>Wrapper: Call Endpoints(ctx) Wrapper->>DedupWrapper: Call Endpoints(ctx) DedupWrapper->>Source: Call Endpoints(ctx) Source-->>DedupWrapper: Return []*Endpoint DedupWrapper-->>Wrapper: Return de-duplicated []*Endpoint Wrapper-->>Plan: Return transformed []*Endpoint ExternalDNS->>Provider: ApplyChanges(plan) Provider-->>ExternalDNS: Sync DNS records ``` ## Learn More - [Source Interface](https://github.com/kubernetes-sigs/external-dns/blob/master/source/source.go) - [Wrappers Source Code](https://github.com/kubernetes-sigs/external-dns/tree/master/source/wrappers) ================================================ FILE: docs/contributing/sources-and-providers.md ================================================ --- tags: - sources - providers - contributing --- # Sources and Providers ExternalDNS supports swapping out endpoint **sources** and DNS **providers** and both sides are pluggable. There currently exist multiple sources for different provider implementations. **Usage** You can choose any combination of sources and providers on the command line. Given a cluster on AWS you would most likely want to use the Service and Ingress Source in combination with the AWS provider. `Service` + `InMemory` is useful for testing your service collecting functionality, whereas `Fake` + `Google` is useful for testing that the Google provider behaves correctly, etc. ## Sources Sources are an abstraction over any kind of source of desired Endpoints, e.g.: * a list of Service objects from Kubernetes * a random list for testing purposes * an aggregated list of multiple nested sources The `Source` interface has a single method called `Endpoints` that should return all desired Endpoint objects as a flat list. ```go type Source interface { Endpoints() ([]*endpoint.Endpoint, error) } ``` All sources live in package `source`. * `ServiceSource`: collects all Services that have an external IP and returns them as Endpoint objects. The desired DNS name corresponds to an annotation set on the Service or is compiled from the Service attributes via the FQDN Go template string. * `IngressSource`: collects all Ingresses that have an external IP and returns them as Endpoint objects. The desired DNS name corresponds to the host rules defined in the Ingress object. * `IstioGatewaySource`: collects all Istio Gateways and returns them as Endpoint objects. The desired DNS name corresponds to the hosts listed within the servers spec of each Gateway object. * `ContourIngressRouteSource`: collects all Contour IngressRoutes and returns them as Endpoint objects. The desired DNS name corresponds to the `virtualhost.fqdn` listed within the spec of each IngressRoute object. * `FakeSource`: returns a random list of Endpoints for the purpose of testing providers without having access to a Kubernetes cluster. * `ConnectorSource`: returns a list of Endpoint objects which are served by a tcp server configured through `connector-source-server` flag. * `CRDSource`: returns a list of Endpoint objects sourced from the spec of CRD objects. For more details refer to [CRD source](../sources/crd.md) documentation. * `EmptySource`: returns an empty list of Endpoint objects for the purpose of testing and cleaning out entries. ### Adding New Sources When creating a new source, add the following annotations above the source struct definition: ```go // myNewSource is an implementation of Source for MyResource objects. // // +externaldns:source:name=my-new-source // +externaldns:source:category=Kubernetes Core // +externaldns:source:description=Creates DNS entries from MyResource objects // +externaldns:source:resources=MyResource // +externaldns:source:filters= // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=false // +externaldns:source:events=false|true type myNewSource struct { // ... fields } ``` **Annotation Reference:** * `+externaldns:source:name` - The CLI name used with `--source` flag (required) * `+externaldns:source:category` - Category for documentation grouping (required) * `+externaldns:source:description` - Short description of what the source does (required) * `+externaldns:source:resources` - Kubernetes resources watched (comma-separated). Convention `Kind.apigroup.subdomain.domain` * `+externaldns:source:filters` - Supported filter types (annotation, label) * `+externaldns:source:namespace` - Namespace support: comma-separated values (all, single, multiple) * `+externaldns:source:fqdn-template` - FQDN template support (true, false) * `+externaldns:source:events` - Kubernetes [`events`](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_events/) support (true, false) After adding annotations, run `make generate-sources-documentation` to update sources file. ## Providers Providers are an abstraction over any kind of sink for desired Endpoints, e.g.: * storing them in Google Cloud DNS * printing them to stdout for testing purposes * fanning out to multiple nested providers The `Provider` interface has two methods: `Records` and `ApplyChanges`. `Records` should return all currently existing DNS records converted to Endpoint objects as a flat list. Upon receiving a change set (via an object of `plan.Changes`), `ApplyChanges` should translate these to the provider specific actions in order to persist them in the provider's storage. ```go type Provider interface { Records() ([]*endpoint.Endpoint, error) ApplyChanges(changes *plan.Changes) error } ``` The interface tries to be generic and assumes a flat list of records for both functions. However, many providers scope records into zones. Therefore, the provider implementation has to do some extra work to return that flat list. For instance, the AWS provider fetches the list of all hosted zones before it can return or apply the list of records. If the provider has no concept of zones or if it makes sense to cache the list of hosted zones it is happily allowed to do so. Furthermore, the provider should respect the `--domain-filter` flag to limit the affected records by a domain suffix. For instance, the AWS provider filters out all hosted zones that doesn't match that domain filter. All providers live in package `provider`. * `GoogleProvider`: returns and creates DNS records in Google Cloud DNS * `AWSProvider`: returns and creates DNS records in AWS Route 53 * `AzureProvider`: returns and creates DNS records in Azure DNS * `InMemoryProvider`: Keeps a list of records in local memory ### Implementing GetDomainFilter `GetDomainFilter()` is a method on the `Provider` interface. The default implementation in `BaseProvider` returns an empty filter with no effect. Providers can override it to contribute an additional domain constraint to the reconcile plan, on top of whatever the user configured via `--domain-filter`. #### How the controller uses it Each reconcile cycle, the controller builds a plan combining two filters: ```go DomainFilter: endpoint.MatchAllDomainFilters{c.DomainFilter, registryFilter} ``` * `c.DomainFilter` — from the `--domain-filter` CLI flag (user-supplied) * `registryFilter` — the value returned by `provider.GetDomainFilter()` `MatchAllDomainFilters` is a logical AND: a record must satisfy both to be included in the plan. The provider filter acts as an additional, provider-side constraint on top of whatever the user configured. #### When to leave the default If your provider has no concept of zones, domains, or hosted zones — for example, a provider backed by flat storage like etcd — the `BaseProvider` default is fine. Do not override it just to echo `config.DomainFilter` back. For example, if the user runs with `--domain-filter=example.com` and the provider returns the same value, the plan sees: ```go MatchAllDomainFilters{example.com, example.com} // same filter twice, no added value ``` This is functionally identical to the default and adds no protection. #### When and how to override — the dynamic pattern Override `GetDomainFilter()` when your provider has an authoritative list of zones, domains, or hosted zones it manages — regardless of what the DNS provider calls them — and can narrow the scope independently of what the user configured. Two concrete benefits make this worthwhile: **Protection without user configuration** — when no `--domain-filter` is set, `BaseProvider` returns an empty filter and the controller has no domain constraint at all. A dynamic override builds the constraint from zones the provider actually manages, so the controller is scoped correctly even if the operator never sets a flag. **The filter reflects reality, not intent** — `--domain-filter` expresses what the operator wants to manage. `GetDomainFilter()` expresses what the provider actually manages at runtime — zones that exist and are accessible with the current credentials. The intersection of the two is tighter and safer than either alone. For example, if `--domain-filter=example.com` is set but the provider only has access to `api.example.com` and `prod.example.com`, a dynamic implementation scopes the plan to exactly those two zones rather than anything under `example.com`. The correct approach is to query your zone API at runtime and build the filter from the zones your provider actually controls. `AWSProvider.GetDomainFilter()` is the canonical example: ```go func (p *MyProvider) GetDomainFilter() endpoint.DomainFilterInterface { zones, err := p.zones() if err != nil { return &endpoint.DomainFilter{} } // Apply your own configured filter to keep only zones this provider manages. filteredZones := applyDomainFilter(zones) names := make([]string, 0, len(zones)) for _, z := range filteredZones { names = append(names, z.Name, "."+z.Name) } return endpoint.NewDomainFilter(names) } ``` Each zone name is added twice — as a bare domain (`example.com`) and with a leading dot (`.example.com`) — so the filter matches both exact records and subdomains. For example, suppose the provider manages four zones: ```sh api.example.com prod.myapp.io staging.myapp.io legacy.internal.net ``` **Without `--domain-filter`** — the provider filter alone constrains the plan: ```go MatchAllDomainFilters{ , // no CLI flag, matches everything [api.example.com, .api.example.com, prod.myapp.io, .prod.myapp.io, staging.myapp.io, .staging.myapp.io, legacy.internal.net, .legacy.internal.net], // only provider-managed zones } ``` The controller will only touch records in those four zones. Any other zone in the cluster is left untouched, even if records pointing to it appear in sources. **With `--domain-filter=myapp.io`** — the two filters intersect: ```go MatchAllDomainFilters{ myapp.io, // CLI flag [api.example.com, .api.example.com, prod.myapp.io, .prod.myapp.io, staging.myapp.io, .staging.myapp.io, legacy.internal.net, .legacy.internal.net], } ``` Only `prod.myapp.io` and `staging.myapp.io` satisfy both filters and are in scope. `api.example.com` and `legacy.internal.net` are excluded by the CLI filter. On error, return an empty `&endpoint.DomainFilter{}`. This has the same effect as the `BaseProvider` default — the CLI filter becomes the sole authority. If the user specifies a domain the provider does not manage, reconciliation will proceed against it. This is a deliberate tradeoff: a temporary API failure should not block all reconciliation. For example, if the provider manages `a.com` and `b.com` but the user sets `--domain-filter=c.com`, a dynamic implementation produces an empty intersection — the controller does nothing: ```go MatchAllDomainFilters{ c.com, // CLI flag [a.com, .a.com, // provider zones — no overlap with c.com b.com, .b.com], } ``` With an empty `GetDomainFilter()` (default or error), only the CLI filter applies and the controller attempts to reconcile `c.com` against a provider that does not manage it. #### Zone name formatting Check the format your provider's API returns for zone names before passing them to `endpoint.NewDomainFilter`. Some APIs include a trailing dot (`"example.com."`), which must be stripped first: ```go // API returns: "foo.example.com." // Filter needs: "foo.example.com" name := strings.TrimSuffix(z.Name, ".") names = append(names, name, "."+name) ``` #### Summary | Implementation | `--domain-filter` unset | `--domain-filter` set | |---------------------------------------|--------------------------------------------|----------------------------------------------| | `BaseProvider` default | No additional constraint | User filter applied | | Static (echoes `config.DomainFilter`) | No additional constraint (same as default) | Same filter applied twice — redundant | | Dynamic (`ListZones` + filter) | Provider-managed zones constrain the plan | Intersection of user filter + provider zones | The dynamic approach is what gives `GetDomainFilter()` its value: when no `--domain-filter` is set, it prevents the controller from touching records in zones the provider does not manage. #### Testing `GetDomainFilter()` must have a unit test. See `TestAWSProvider_GetDomainFilter` for a reference. At minimum, test that: * Zone names are correctly mapped to filter entries (including the leading-dot variant) * An error from `ListZones` returns an empty `DomainFilter` gracefully ## Provider Blueprints The `provider/blueprint` package contains reusable building blocks for provider implementations. Using them keeps providers consistent and avoids reimplementing solved problems. ### ZoneCache `ZoneCache[T]` is a generic, thread-safe TTL cache for zone, domain, or hosted zone data. See `provider/blueprint/zone_cache.go` for the full API and godoc. **Reduced API pressure** — listing zones, domains, or hosted zones is called on every reconcile cycle, but they are rarely created or deleted. Caching the result for a configurable TTL means the provider only hits the API when the cache has expired, rather than on every loop. **Consistent behaviour across providers** — thread safety, TTL logic, and the disable-via-zero behaviour are implemented and tested once in `blueprint`. Providers that use `ZoneCache` behave the same way, reducing drift between implementations over time. The typical usage pattern — taken from `AWSProvider.zones()` — is: ```go // On the provider struct: zonesCache *blueprint.ZoneCache[map[string]*MyZone] // In the constructor: zonesCache: blueprint.NewZoneCache[map[string]*MyZone](config.ZoneCacheDuration), // In the zone/domain-listing method: func (p *MyProvider) zones() (map[string]*MyZone, error) { if !p.zonesCache.Expired() { return p.zonesCache.Get(), nil } zones, err := p.client.ListZones() if err != nil { return nil, err } p.zonesCache.Reset(zones) return zones, nil } ``` Full behaviour is documented in the `ZoneCache` godoc. The key contract to keep in mind when implementing the pattern: `Get()` returns stale data after expiry rather than a zero value — callers must check `Expired()` first and decide whether to refresh. ### Configuration flag `ZoneCache` is controlled by a single shared flag: | Flag | Default | Description | |--------------------------|---------|----------------------------------------------| | `--zones-cache-duration` | `0s` | Zone list cache TTL. Set to `0s` to disable. | Add a `ZoneCacheDuration time.Duration` field to your provider config struct, wire it to this flag in `pkg/apis/externaldns/types.go`, and pass it to `NewZoneCache` in the constructor. ================================================ FILE: docs/deprecation.md ================================================ # External DNS Deprecation Policy This document defines the Deprecation Policy for External DNS. Kubernetes is a dynamic system driven by APIs, which evolve with each new release. A crucial aspect of any API-driven system is having a well-defined deprecation policy. This policy informs users about APIs that are slated for removal or modification. Kubernetes follows this principle and periodically refines or upgrades its APIs or capabilities. Consequently, older features are marked as deprecated and eventually phased out. To avoid breaking existing users, we should follow a simple deprecation policy for behaviors that a slated to be removed. The features and capabilities either to evolve or need to be removed. ## Deprecation Policy We follow the [Kubernetes Deprecation Policy](https://kubernetes.io/docs/reference/using-api/deprecation-policy/) and [API Versioning Scheme](https://kubernetes.io/docs/reference/using-api/#api-versioning): alpha, beta, GA. It is therefore important to be aware of deprecation announcements and know when API versions will be removed, to help minimize the effect. ### Scope * CRDs and API Objects and fields: `.Spec`, `.Status` and `.Status.Conditions[]` * Annotations objects or it's values * Controller Configuration: CLI flags & environment variables * Metrics as defined in the [Kubernetes docs](https://kubernetes.io/docs/reference/using-api/deprecation-policy/#deprecating-a-metric) * Revert a specific behavior without an alternative (flag,crd or annotation) ### Non-Scope Everything not listed in scope is not subject to this deprecation policy and it is subject to breaking changes, updates at any point in time, and deprecation - as long as it follows the Deprecation Process listed below. This includes, but isn't limited to: * Any feature/specific behavior not in Scope. * Source code imports * Source code refactorings * Helm Charts * Release process * Docker Images (including multi-arch builds) * Image Signature (including provenance, providers, keys) ## Including features and behaviors to the Deprecation Policy Any `maintainer` or `contributor` may propose including a feature, component, or behavior out of scope to be in scope of the deprecation policy. The proposal must clearly outline the rationale for inclusion, the impact on users, stability, long term maintenance plan, and day-to-day activities, if such. The proposal must be formalized by submitting a `docs/proposal/EDP-XXX.md` document in a Pull Request. Pull request must be labeled with `kind/proposal`. The proposal [template location is here](./proposal/design-template.md). The template is quite complete, one can remove any unnecessary or irrelevant section on a specific proposal. ## Deprecation Process ### Nomination of Deprecation Any maintainer may propose deprecating a feature, component, or behavior (both in and out of scope). In Scope changes must abide to the Deprecation Policy above. The proposal must clearly outline the rationale for deprecation, the impact on users, and any alternatives, if such. The proposal must be formalized by submiting a `design` document as a Pull Request. ### Showcase to Maintainers The proposing maintainer must present the proposed deprecation to the maintainer group. This can be done synchronously during a community meeting or asynchronously, through a GitHub Pull Request. ### Voting A majority vote of maintainers is required to approve the deprecation. Votes may be conducted asynchronously, with a reasonable deadline for responses (e.g., one week). Lazy Consensus applies if the reasonable deadline is extended, with a minimal of at least one other maintainer approving the changes. ### Implementation Upon approval, the proposing maintainer is responsible for implementing the changes required to mark the feature as deprecated. This includes: * Updating the codebase with deprecation warnings where applicable. * log.Warn("The XXX is on the path of ***DEPRECATION***. We recommend that you use YYY (link to docs)") * Documenting the deprecation in release notes and relevant documentation. * Updating APIs, metrics, or behaviors per the Kubernetes Deprecation Policy if in scope. * If the feature is entirely deprecated, archival of any associated repositories (external provider as example). ### Deprecation Notice in Release Deprecation must be introduced in the next release. The release must follow semantic versioning: * If the project is in the 0.x stage, a `minor` version `bump` is required. * For projects 1.x and beyond, a major version bump is required. For the features completely removed. * If it's a flag change/flip, the `minor` version `bump` is acceptable ### Full Deprecation and Removal The removal must follow standard Kubernetes deprecation timelines if the feature is in scope. ================================================ FILE: docs/faq.md ================================================ # Frequently asked questions ## How is ExternalDNS useful to me? You've probably created many deployments. Typically, you expose your deployment to the Internet by creating a Service with `type=LoadBalancer`. Depending on your environment, this usually assigns a random publicly available endpoint to your service that you can access from anywhere in the world. On Google Kubernetes Engine, this is a public IP address: ```console $ kubectl get svc NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE nginx 10.3.249.226 35.187.104.85 80:32281/TCP 1m ``` But dealing with IPs for service discovery isn't nice, so you register this IP with your DNS provider under a better name—most likely, one that corresponds to your service name. If the IP changes, you update the DNS record accordingly. Those times are over! ExternalDNS takes care of that last step for you by keeping your DNS records synchronized with your external entry points. ExternalDNS' usefulness also becomes clear when you use Ingresses to allow external traffic into your cluster. Via Ingress, you can tell Kubernetes to route traffic to different services based on certain HTTP request attributes, e.g. the Host header: ```console $ kubectl get ing NAME HOSTS ADDRESS PORTS AGE entrypoint frontend.example.org,backend.example.org 35.186.250.78 80 1m ``` But there's nothing that actually makes clients resolve those hostnames to the Ingress' IP address. Again, you normally have to register each entry with your DNS provider. Only if you're lucky can you use a wildcard, like in the example above. ExternalDNS can solve this for you as well. ## Which DNS providers are supported? Please check the [provider status table](https://github.com/kubernetes-sigs/external-dns#status-of-in-tree-providers) for the list of supported providers and their status. As stated in the README, we are currently looking for stable maintainers for those providers, to ensure that bugfixes and new features will be available for all of those. ## Which Kubernetes objects are supported? Services exposed via `type=LoadBalancer`, `type=ExternalName`, `type=NodePort`, and for the hostnames defined in Ingress objects as well as [headless hostPort](tutorials/hostport.md) services. ## How do I specify a DNS name for my Kubernetes objects? There are three sources of information for ExternalDNS to decide on DNS name. ExternalDNS will pick one in order as listed below: 1. For ingress objects ExternalDNS will create a DNS record based on the hosts specified for the ingress object, as well as the `external-dns.alpha.kubernetes.io/hostname` annotation. - For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the loadbalancer IP, it also will look for the annotation `external-dns.alpha.kubernetes.io/internal-hostname` on the service and use the service IP. - For ingresses, you can optionally force ExternalDNS to create records based on _either_ the hosts specified or the `external-dns.alpha.kubernetes.io/hostname` annotation. This behavior is controlled by setting the `external-dns.alpha.kubernetes.io/ingress-hostname-source` annotation on that ingress to either `defined-hosts-only` or `annotation-only`. 2. If compatibility mode is enabled (e.g. `--compatibility={mate,molecule}` flag), External DNS will parse annotations used by Zalando/Mate, wearemolecule/route53-kubernetes. Compatibility mode with Kops DNS Controller is planned to be added in the future. 3. If `--fqdn-template` flag is specified, e.g. `--fqdn-template={{.Name}}.my-org.com`, ExternalDNS will use service/ingress specifications for the provided template to generate DNS name. ## Which Service and Ingress controllers are supported? Regarding Services, we'll support the OSI Layer 4 load balancers that Kubernetes creates on AWS and Google Kubernetes Engine, and possibly other clusters running on Google Compute Engine. Regarding Ingress, we'll support: - Google's Ingress Controller on GKE that integrates with their Layer 7 load balancers (GLBC) - nginx-ingress-controller v0.9.x with a fronting Service - Zalando's [AWS Ingress controller](https://github.com/zalando-incubator/kube-ingress-aws-controller), based on AWS ALBs and [Skipper](https://github.com/zalando/skipper) - [Traefik](https://github.com/containous/traefik) - version 1.7, when [`kubernetes.ingressEndpoint`](https://docs.traefik.io/v1.7/configuration/backends/kubernetes/#ingressendpoint) is configured (`kubernetes.ingressEndpoint.useDefaultPublishedService` in the [Helm chart](https://github.com/helm/charts/tree/HEAD/stable/traefik#configuration)) - versions \>=2.0, when [`providers.kubernetesIngress.ingressEndpoint`](https://doc.traefik.io/traefik/providers/kubernetes-ingress/#ingressendpoint) is configured (`providers.kubernetesIngress.publishedService.enabled` is set to `true` in the [new Helm chart](https://github.com/traefik/traefik-helm-chart)) ## Are other Ingress Controllers supported? For Ingress objects, ExternalDNS will attempt to discover the target hostname of the relevant Ingress Controller automatically. If you are using an Ingress Controller that is not listed above you may have issues with ExternalDNS not discovering Endpoints and consequently not creating any DNS records. As a workaround, it is possible to force create an Endpoint by manually specifying a target host/IP for the records to be created by setting the annotation `external-dns.alpha.kubernetes.io/target` in the Ingress object. Another reason you may want to override the ingress hostname or IP address is if you have an external mechanism for handling failover across ingress endpoints. Possible scenarios for this would include using [keepalived-vip](https://github.com/kubernetes/contrib/tree/HEAD/keepalived-vip) to manage failover faster than DNS TTLs might expire. Note that if you set the target to a hostname, then a CNAME record will be created. In this case, the hostname specified in the Ingress object's annotation must already exist. (i.e. you have a Service resource for your Ingress Controller with the `external-dns.alpha.kubernetes.io/hostname` annotation set to the same value) ## What about other projects similar to ExternalDNS? ExternalDNS is a joint effort to unify different projects accomplishing the same goals, namely: - Kops' [DNS Controller](https://github.com/kubernetes/kops/tree/HEAD/dns-controller) - Zalando's [Mate](https://github.com/linki/mate) - Molecule Software's [route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes) We strive to make the migration from these implementations a smooth experience. This means that, for some time, we'll support their annotation semantics in ExternalDNS and allow both implementations to run side-by-side. This enables you to migrate incrementally and slowly phase out the other implementation. ## How does it work with other implementations and legacy records? ExternalDNS will allow you to opt into any Services and Ingresses that you want it to consider, by an annotation. This way, it can co-exist with other implementations running in the same cluster if they also support this pattern. However, we'll most likely declare ExternalDNS to be the default implementation. This means that ExternalDNS will consider Services and Ingresses that don't specifically declare which controller they want to be processed by; this is similar to the `ingress.class` annotation on GKE. ## I'm afraid you will mess up my DNS records Since v0.3, ExternalDNS can be configured to use an ownership registry. When this option is enabled, ExternalDNS will keep track of which records it has control over, and will never modify any records over which it doesn't have control. This is a fundamental requirement to operate ExternalDNS safely when there might be other actors creating DNS records in the same target space. For now ExternalDNS uses TXT records to label owned records, and there might be other alternatives coming in the future releases. ## Does anyone use ExternalDNS in production? Yes, multiple companies are using ExternalDNS in production. Zalando, as an example, has been using it in production since its v0.3 release, mostly using the AWS provider. ## How can we start using ExternalDNS? Check out the following descriptive tutorials on how to run ExternalDNS in [GKE](tutorials/gke.md) and [AWS](tutorials/aws.md) or any other supported provider. ## Why is ExternalDNS only adding a single IP address in Route 53 on AWS when using the `nginx-ingress-controller` ? By default the `nginx-ingress-controller` assigns a single IP address to an Ingress resource when it's created. ExternalDNS uses what's assigned to the Ingress resource, so it too will use this single IP address when adding the record in Route 53. ### How do I get it to use the FQDN of the ELB assigned to my `nginx-ingress-controller` Service instead? In most AWS deployments, you'll instead want the Route 53 entry to be the FQDN of the ELB that is assigned to the `nginx-ingress-controller` Service. To accomplish this, when you create the `nginx-ingress-controller` Deployment, you need to provide the `--publish-service` option to the `/nginx-ingress-controller` executable under `args`. Once this is deployed new Ingress resources will get the ELB's FQDN and ExternalDNS will use the same when creating records in Route 53. According to the `nginx-ingress-controller` [docs](https://kubernetes.github.io/ingress-nginx/) the value you need to provide `--publish-service` is: > Service fronting the ingress controllers. Takes the form namespace/name. The controller will set the endpoint records on the ingress objects to reflect those on the service. For example if your `nginx-ingress-controller` Service's name is `nginx-ingress-controller-svc` and it's in the `default` namespace the start of your resource YAML might look like the following. Note the second to last line. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx-ingress-controller spec: replicas: 1 selector: matchLabels: app: nginx-ingress template: metadata: labels: app: nginx-ingress spec: hostNetwork: false containers: - name: nginx-ingress-controller image: "gcr.io/google_containers/nginx-ingress-controller:0.9.0-beta.11" imagePullPolicy: "IfNotPresent" args: - /nginx-ingress-controller - --default-backend-service={your-backend-service} - --publish-service=default/nginx-ingress-controller-svc - --configmap={your-configmap} ``` ## I have a Service/Ingress but it's ignored by ExternalDNS. Why? ExternalDNS can be configured to only use Services or Ingresses as source. In case Services or Ingresses seem to be ignored in your setup, consider checking how the flag `--source` was configured when deployed. For reference, see the issue https://github.com/kubernetes-sigs/external-dns/issues/267. ## I'm using an ELB with TXT registry but the CNAME record clashes with the TXT record. How to avoid this? CNAMEs cannot co-exist with other records, therefore you can use the `--txt-prefix` flag which makes sure to create a TXT record with a name following the pattern `prefix.`. For reference, see the issue https://github.com/kubernetes-sigs/external-dns/issues/262. ## Can I force ExternalDNS to create CNAME records for ELB/ALB? The default logic is: when a target looks like an ELB/ALB, ExternalDNS will create ALIAS records for it. Under certain circumstances you want to force ExternalDNS to create CNAME records instead. If you want to do that, start ExternalDNS with the `--aws-prefer-cname` flag. Why should I want to force ExternalDNS to create CNAME records for ELB/ALB? Some motivations of users were: > "Our hosted zones records are synchronized with our enterprise DNS. The record type ALIAS is an AWS proprietary record type and AWS allows you to set a DNS record directly on AWS resources. > Since this is not a DNS RFC standard and therefore can not be transferred and created in our enterprise DNS. So we need to force CNAME creation instead." or > "In case of ALIAS if we do nslookup with domain name, it will return only IPs of ELB. So it is always difficult for us to locate ELB in AWS console to which domain is pointing. If we configure it with CNAME it will return exact ELB CNAME, which is more helpful.!" ## Which permissions do I need when running ExternalDNS on a GCE or GKE node You need to add either https://www.googleapis.com/auth/ndev.clouddns.readwrite or https://www.googleapis.com/auth/cloud-platform on your instance group's scope. ## How can I run ExternalDNS under a specific GCP Service Account, e.g. to access DNS records in other projects? Have a look at https://github.com/linki/mate/blob/v0.6.2/examples/google/README.md#permissions ## How do I configure multiple Sources via environment variables? (also applies to domain filters) Separate the individual values via a line break. The equivalent of `--source=service --source=ingress` would be `service\ningress`. However, it can be tricky do define that depending on your environment. The following examples work (zsh): Via docker: ```console $ docker run \ -e EXTERNAL_DNS_SOURCE=$'service\ningress' \ -e EXTERNAL_DNS_PROVIDER=google \ -e EXTERNAL_DNS_DOMAIN_FILTER=$'foo.com\nbar.com' \ registry.k8s.io/external-dns/external-dns:v0.20.0 time="2017-08-08T14:10:26Z" level=info msg="config: &{APIServerURL: KubeConfig: Sources:[service ingress] Namespace: ... ``` Locally: ```console $ export EXTERNAL_DNS_SOURCE=$'service\ningress' $ external-dns --provider=google INFO[0000] config: &{APIServerURL: KubeConfig: Sources:[service ingress] Namespace: ... ``` ```sh $ EXTERNAL_DNS_SOURCE=$'service\ningress' external-dns --provider=google INFO[0000] config: &{APIServerURL: KubeConfig: Sources:[service ingress] Namespace: ... ``` In a Kubernetes manifest: ```yaml spec: containers: - name: external-dns args: - --provider=google env: - name: EXTERNAL_DNS_SOURCE value: "service\ningress" ``` Or preferably: ```yaml spec: containers: - name: external-dns args: - --provider=google env: - name: EXTERNAL_DNS_SOURCE value: |- service ingress ``` ## Running an internal and external dns service Sometimes you need to run an internal and an external dns service. The internal one should provision hostnames used on the internal network (perhaps inside a VPC), and the external one to expose DNS to the internet. To do this with ExternalDNS you can use the `--ingress-class` flag to specifically tie an instance of ExternalDNS to an instance of a ingress controller. Let's assume you have two ingress controllers, `internal` and `external`. You can then start two ExternalDNS providers, one with `--ingress-class=internal` and one with `--ingress-class=external`. If you need to search for multiple ingress classes, you can specify the flag multiple times, like so: `--ingress-class=internal --ingress-class=external`. The `--ingress-class` flag will check both the `spec.ingressClassName` field and the deprecated `kubernetes.io/ingress.class` annotation. The `spec.ingressClassName` takes precedence over the annotation if both are supplied. **Backward compatibility** The previous `--annotation-filter` flag can still be used to restrict which objects ExternalDNS considers; for example, `--annotation-filter=kubernetes.io/ingress.class in (public,dmz)`. However, beware when using annotation filters with multiple sources, e.g. `--source=service --source=ingress`, since `--annotation-filter` will filter every given source object. If you need to use annotation filters against a specific source you have to run a separated external dns service containing only the wanted `--source` and `--annotation-filter`. Note: the `--ingress-class` flag cannot be used at the same time as the `--annotation-filter=kubernetes.io/ingress.class in (...)` flag; if you do this an error will be raised. **Performance considerations** Filtering based on ingress class name or annotations means that the external-dns controller will receive all resources of that kind and then filter on the client-side. In larger clusters with many resources which change frequently this can cause performance issues. If only some resources need to be managed by an instance of external-dns then label filtering can be used instead of ingress class filtering (or legacy annotation filtering). This means that only those resources which match the selector specified in `--label-filter` will be passed to the controller. **Split horizon DNS with custom annotation prefixes** For more advanced split horizon scenarios, you can use the `--annotation-prefix` flag to configure different instances to read different sets of annotations from the same resources. This is useful when you want a single Service or Ingress to create records in multiple DNS zones (e.g., internal and external). For example: ```bash # Internal DNS instance --annotation-prefix=internal.company.io/ --provider=aws --aws-zone-type=private # External DNS instance --annotation-prefix=external-dns.alpha.kubernetes.io/ --provider=aws --aws-zone-type=public ``` Then annotate your resources with both prefixes: ```yaml metadata: annotations: internal.company.io/hostname: app.internal.company.com external-dns.alpha.kubernetes.io/hostname: app.company.com ``` See the [Split Horizon DNS guide](advanced/split-horizon.md) for detailed examples and configuration. ## How do I specify that I want the DNS record to point to either the Node's public or private IP when it has both? If your Nodes have both public and private IP addresses, you might want to write DNS records with one or the other. For example, you may want to write a DNS record in a private zone that resolves to your Nodes' private IPs so that traffic never leaves your private network. To accomplish this, set this annotation on your service: `external-dns.alpha.kubernetes.io/access=private` Conversely, to force the public IP: `external-dns.alpha.kubernetes.io/access=public` If this annotation is not set, and the node has both public and private IP addresses, then the public IP will be used by default. Some loadbalancer implementations assign multiple IP addresses as external addresses. You can filter the generated targets by their networks using `--target-net-filter=10.0.0.0/8` or `--exclude-target-net=10.0.0.0/8`. ## Can external-dns manage(add/remove) records in a hosted zone which is setup in different AWS account? Yes, give it the correct cross-account/assume-role permissions and use the `--aws-assume-role` flag https://github.com/kubernetes-sigs/external-dns/pull/524#issue-181256561 ## How do I provide multiple values to the annotation `external-dns.alpha.kubernetes.io/hostname`? Separate them by `,`. ## Are there official Docker images provided? When we tag a new release, we push a container image to the Kubernetes projects official container registry with the following name: ```sh registry.k8s.io/external-dns/external-dns ``` As tags, you use the external-dns release of choice(i.e. `v0.7.6`). A `latest` tag is not provided in the container registry. If you wish to build your own image, you can use the provided [.ko.yaml](https://github.com/kubernetes-sigs/external-dns/blob/master/.ko.yaml) as a starting point. ## Which architectures are supported? From `v0.7.5` on we support `amd64`, `arm32v7` and `arm64v8`. This means that you can run ExternalDNS on a Kubernetes cluster backed by Rasperry Pis or on ARM instances in the cloud as well as more traditional machines backed by `amd64` compatible CPUs. ## Which operating systems are supported? At the time of writing we only support GNU/linux and we have no plans of supporting Windows or other operating systems. ## Why am I seeing time out errors even though I have connectivity to my cluster? If you're seeing an error such as this: ```sh FATA[0060] failed to sync cache: timed out waiting for the condition ``` You may not have the correct permissions required to query all the necessary resources in your kubernetes cluster. Specifically, you may be running in a `namespace` that you don't have these permissions in. By default, commands are run against the `default` namespace. Try changing this to your particular namespace to see if that fixes the issue. ## When we plan to release a v1.0, our first `major` release? > We should really get away from 0.x only if we have APIs that we can declare stable. The jump to `1.0` isn’t just symbolic—it’s a promise. If the `External-DNS` maintainers can confidently say that config structures, CRDs, and flags won’t break unexpectedly, that’s the moment to move to `1.0` Before moving to `1.0`, review and lock down: - CRD schemas (especially DNSEndpoint if applicable) - Annotations support - Command-line flags and configuration behavior - Environment variables and metrics - Provider interface stability - Once these are considered stable and documented, then a `1.0` tag makes sense. ================================================ FILE: docs/flags.md ================================================ --- tags: - flags - autogenerated --- # Flags | Flag | Description | |:-------------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `--[no-]version` | Show application version. | | `--server=""` | The Kubernetes API server to connect to (default: auto-detect) | | `--kubeconfig=""` | Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect) | | `--request-timeout=30s` | Request timeout when calling Kubernetes APIs. 0s means no timeout | | `--[no-]resolve-service-load-balancer-hostname` | Resolve the hostname of LoadBalancer-type Service object to IP addresses in order to create DNS A/AAAA records instead of CNAMEs | | `--[no-]listen-endpoint-events` | Trigger a reconcile on changes to EndpointSlices, for Service source (default: false) | | `--gloo-namespace=gloo-system` | The Gloo Proxy namespace; specify multiple times for multiple namespaces. (default: gloo-system) | | `--skipper-routegroup-groupversion="zalando.org/v1"` | The resource version for skipper routegroup | | `--[no-]always-publish-not-ready-addresses` | Always publish also not ready addresses for headless services (optional) | | `--annotation-filter=""` | Filter resources queried for endpoints by annotation, using label selector semantics | | `--annotation-prefix="external-dns.alpha.kubernetes.io/"` | Annotation prefix for external-dns annotations (default: external-dns.alpha.kubernetes.io/) | | `--compatibility=` | Process annotation semantics from legacy implementations (optional, options: mate, molecule, kops-dns-controller) | | `--connector-source-server="localhost:8080"` | The server to connect for connector source, valid only when using connector source | | `--crd-source-apiversion="externaldns.k8s.io/v1alpha1"` | API version of the CRD for crd source, e.g. `externaldns.k8s.io/v1alpha1`, valid only when using crd source | | `--crd-source-kind="DNSEndpoint"` | Kind of the CRD for the crd source in API group and version specified by crd-source-apiversion | | `--default-targets=DEFAULT-TARGETS` | Set globally default host/IP that will apply as a target instead of source addresses. Specify multiple times for multiple targets (optional) | | `--[no-]force-default-targets` | Force the application of --default-targets, overriding any targets provided by the source (DEPRECATED: This reverts to (improved) legacy behavior which allows empty CRD targets for migration to new state) | | `--[no-]prefer-alias` | When enabled, CNAME records will have the alias annotation set, signaling providers that support ALIAS records to use them instead of CNAMEs. Supported by: PowerDNS, AWS (with --aws-prefer-cname disabled) | | `--exclude-record-types=EXCLUDE-RECORD-TYPES` | Record types to exclude from management; specify multiple times to exclude many; (optional) | | `--exclude-target-net=EXCLUDE-TARGET-NET` | Exclude target nets (optional) | | `--[no-]exclude-unschedulable` | Exclude nodes that are considered unschedulable (default: true) | | `--[no-]expose-internal-ipv6` | When using the node source, expose internal IPv6 addresses (optional, default: false) | | `--gateway-label-filter=""` | Filter Gateways of Route endpoints via label selector (default: all gateways) | | `--gateway-name=""` | Limit Gateways of Route endpoints to a specific name (default: all names) | | `--gateway-namespace=""` | Limit Gateways of Route endpoints to a specific namespace (default: all namespaces) | | `--[no-]ignore-hostname-annotation` | Ignore hostname annotation when generating DNS names, valid only when --fqdn-template is set (default: false) | | `--[no-]ignore-ingress-rules-spec` | Ignore the spec.rules section in Ingress resources (default: false) | | `--[no-]ignore-ingress-tls-spec` | Ignore the spec.tls section in Ingress resources (default: false) | | `--[no-]ignore-non-host-network-pods` | Ignore pods not running on host network when using pod source (default: false) | | `--ingress-class=INGRESS-CLASS` | Require an Ingress to have this class name; specify multiple times to allow more than one class (optional; defaults to any class) | | `--label-filter=""` | Filter resources queried for endpoints by label selector; currently supported by source types crd, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, ingress, node, openshift-route, service and ambassador-host | | `--managed-record-types=A...` | Record types to manage; specify multiple times to include many; (default: A,AAAA,CNAME) (supported records: A, AAAA, CNAME, NS, SRV, TXT) | | `--namespace=""` | Limit resources queried for endpoints to a specific namespace (default: all namespaces) | | `--nat64-networks=NAT64-NETWORKS` | Adding an A record for each AAAA record in NAT64-enabled networks; specify multiple times for multiple possible nets (optional) | | `--openshift-router-name=""` | if source is openshift-route then you can pass the ingress controller name. Based on this name external-dns will select the respective router from the route status and map that routerCanonicalHostname to the route host while creating a CNAME record. | | `--pod-source-domain=""` | Domain to use for pods records (optional) | | `--[no-]publish-host-ip` | Allow external-dns to publish host-ip for headless services (optional) | | `--[no-]publish-internal-services` | Allow external-dns to publish DNS records for ClusterIP services (optional) | | `--service-type-filter=SERVICE-TYPE-FILTER` | The service types to filter by. Specify multiple times for multiple filters to be applied. (optional, default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName) | | `--target-net-filter=TARGET-NET-FILTER` | Limit possible targets by a net filter; specify multiple times for multiple possible nets (optional) | | `--[no-]traefik-enable-legacy` | Enable legacy listeners on Resources under the traefik.containo.us API Group | | `--[no-]traefik-disable-new` | Disable listeners on Resources under the traefik.io API Group | | `--unstructured-resource=UNSTRUCTURED-RESOURCE` | When using the unstructured source, specify resources in resource.version.group format (e.g., virtualmachineinstances.v1.kubevirt.io, configmap.v1); specify multiple times for multiple resources | | `--events-emit=EVENTS-EMIT` | Events that should be emitted. Specify multiple times for multiple events support (optional, default: none, expected: RecordReady, RecordDeleted, RecordError) | | `--provider-cache-time=0s` | The time to cache the DNS provider record list requests. | | `--domain-filter=` | Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional) | | `--exclude-domains=` | Exclude subdomains (optional) | | `--regex-domain-filter=` | Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional) | | `--regex-domain-exclusion=` | Regex filter that excludes domains and target zones matched by regex-domain-filter (optional) | | `--zone-name-filter=` | Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional) | | `--zone-id-filter=` | Filter target zones by hosted zone id; specify multiple times for multiple zones (optional) | | `--google-project=""` | When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP. | | `--google-batch-change-size=1000` | When using the Google provider, set the maximum number of changes that will be applied in each batch. | | `--google-batch-change-interval=1s` | When using the Google provider, set the interval between batch changes. | | `--google-zone-visibility=` | When using the Google provider, filter for zones with this visibility (optional, options: public, private) | | `--alibaba-cloud-config-file="/etc/kubernetes/alibaba-cloud.json"` | When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud) | | `--alibaba-cloud-zone-type=` | When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private) | | `--aws-zone-type=` | When using the AWS provider, filter for zones of this type (optional, default: any, options: public, private) | | `--aws-zone-tags=` | When using the AWS provider, filter for zones with these tags | | `--aws-profile=` | When using the AWS provider, name of the profile to use | | `--aws-assume-role=""` | When using the AWS API, assume this IAM role. Useful for hosted zones in another AWS account. Specify the full ARN, e.g. `arn:aws:iam::123455567:role/external-dns` (optional) | | `--aws-assume-role-external-id=""` | When using the AWS API and assuming a role then specify this external ID` (optional) | | `--aws-batch-change-size=1000` | When using the AWS provider, set the maximum number of changes that will be applied in each batch. | | `--aws-batch-change-size-bytes=32000` | When using the AWS provider, set the maximum byte size that will be applied in each batch. | | `--aws-batch-change-size-values=1000` | When using the AWS provider, set the maximum total record values that will be applied in each batch. | | `--aws-batch-change-interval=1s` | When using the AWS provider, set the interval between batch changes. | | `--[no-]aws-evaluate-target-health` | When using the AWS provider, set whether to evaluate the health of a DNS target (default: enabled, disable with --no-aws-evaluate-target-health) | | `--aws-api-retries=3` | When using the AWS API, set the maximum number of retries before giving up. | | `--[no-]aws-prefer-cname` | When using the AWS provider, prefer using CNAME instead of ALIAS (default: disabled) | | `--aws-zones-cache-duration=0s` | When using the AWS provider, set the zones list cache TTL (0s to disable). | | `--[no-]aws-zone-match-parent` | Expand limit possible target by sub-domains (default: disabled) | | `--[no-]aws-sd-service-cleanup` | When using the AWS CloudMap provider, delete empty Services without endpoints (default: disabled) | | `--aws-sd-create-tag=AWS-SD-CREATE-TAG` | When using the AWS CloudMap provider, add tag to created services. The flag can be used multiple times | | `--azure-config-file="/etc/kubernetes/azure.json"` | When using the Azure provider, specify the Azure configuration file (required when --provider=azure) | | `--azure-resource-group=""` | When using the Azure provider, override the Azure resource group to use (optional) | | `--azure-subscription-id=""` | When using the Azure provider, override the Azure subscription to use (optional) | | `--azure-user-assigned-identity-client-id=""` | When using the Azure provider, override the client id of user assigned identity in config file (optional) | | `--azure-zones-cache-duration=0s` | When using the Azure provider, set the zones list cache TTL (0s to disable). | | `--azure-maxretries-count=3` | When using the Azure provider, set the number of retries for API calls (When less than 0, it disables retries). (optional) | | `--batch-change-size=200` | Set the maximum number of DNS record changes that will be submitted to the provider in each batch (optional) | | `--batch-change-interval=1s` | Set the interval between batch changes (optional, default: 1s) | | `--[no-]cloudflare-proxied` | When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled) | | `--[no-]cloudflare-custom-hostnames` | When using the Cloudflare provider, specify if the Custom Hostnames feature will be used. Requires "Cloudflare for SaaS" enabled. (default: disabled) | | `--cloudflare-custom-hostnames-min-tls-version=1.0` | When using the Cloudflare provider with the Custom Hostnames, specify which Minimum TLS Version will be used by default. (default: 1.0, options: 1.0, 1.1, 1.2, 1.3) | | `--cloudflare-custom-hostnames-certificate-authority=none` | When using the Cloudflare provider with the Custom Hostnames, specify which Certificate Authority will be used. A value of none indicates no Certificate Authority will be sent to the Cloudflare API (default: none, options: google, ssl_com, lets_encrypt, none) | | `--cloudflare-dns-records-per-page=100` | When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100) | | `--[no-]cloudflare-regional-services` | When using the Cloudflare provider, specify if Regional Services feature will be used (default: disabled) | | `--cloudflare-region-key=""` | When using the Cloudflare provider, specify the default region for Regional Services. Any value other than an empty string will enable the Regional Services feature (optional) | | `--cloudflare-record-comment=""` | When using the Cloudflare provider, specify the comment for the DNS records (default: '') | | `--coredns-prefix="/skydns/"` | When using the CoreDNS provider, specify the prefix name | | `--[no-]coredns-strictly-owned` | When using the CoreDNS provider, store and filter strictly by txt-owner-id using an extra field inside of the etcd service (default: false) | | `--akamai-serviceconsumerdomain=""` | When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified) | | `--akamai-client-token=""` | When using the Akamai provider, specify the client token (required when --provider=akamai and edgerc-path not specified) | | `--akamai-client-secret=""` | When using the Akamai provider, specify the client secret (required when --provider=akamai and edgerc-path not specified) | | `--akamai-access-token=""` | When using the Akamai provider, specify the access token (required when --provider=akamai and edgerc-path not specified) | | `--akamai-edgerc-path=""` | When using the Akamai provider, specify the .edgerc file path. Path must be reachable form invocation environment. (required when --provider=akamai and *-token, secret serviceconsumerdomain not specified) | | `--akamai-edgerc-section=""` | When using the Akamai provider, specify the .edgerc file path (Optional when edgerc-path is specified) | | `--oci-config-file="/etc/kubernetes/oci.yaml"` | When using the OCI provider, specify the OCI configuration file (required when --provider=oci | | `--oci-compartment-ocid=""` | When using the OCI provider, specify the OCID of the OCI compartment containing all managed zones and records. Required when using OCI IAM instance principal authentication. | | `--oci-zone-scope=GLOBAL` | When using OCI provider, filter for zones with this scope (optional, options: GLOBAL, PRIVATE). Defaults to GLOBAL, setting to empty value will target both. | | `--[no-]oci-auth-instance-principal` | When using the OCI provider, specify whether OCI IAM instance principal authentication should be used (instead of key-based auth via the OCI config file). | | `--oci-zones-cache-duration=0s` | When using the OCI provider, set the zones list cache TTL (0s to disable). | | `--inmemory-zone=` | Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional) | | `--ovh-endpoint="ovh-eu"` | When using the OVH provider, specify the endpoint (default: ovh-eu) | | `--ovh-api-rate-limit=20` | When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20) | | `--[no-]ovh-enable-cname-relative` | When using the OVH provider, specify if CNAME should be treated as relative on target without final dot (default: false) | | `--pdns-server="http://localhost:8081"` | When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns) | | `--pdns-server-id="localhost"` | When using the PowerDNS/PDNS provider, specify the id of the server to retrieve. Should be `localhost` except when the server is behind a proxy (optional when --provider=pdns) (default: localhost) | | `--pdns-api-key=""` | When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns) | | `--[no-]pdns-skip-tls-verify` | When using the PowerDNS/PDNS provider, disable verification of any TLS certificates (optional when --provider=pdns) (default: false) | | `--ns1-endpoint=""` | When using the NS1 provider, specify the URL of the API endpoint to target (default: https://api.nsone.net/v1/) | | `--[no-]ns1-ignoressl` | When using the NS1 provider, specify whether to verify the SSL certificate (default: false) | | `--ns1-min-ttl=0` | Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this. | | `--godaddy-api-key=""` | When using the GoDaddy provider, specify the API Key (required when --provider=godaddy) | | `--godaddy-api-secret=""` | When using the GoDaddy provider, specify the API secret (required when --provider=godaddy) | | `--godaddy-api-ttl=0` | TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is not provided. | | `--[no-]godaddy-api-ote` | When using the GoDaddy provider, use OTE api (optional, default: false, when --provider=godaddy) | | `--tls-ca=""` | When using TLS communication, the path to the certificate authority to verify server communications (optionally specify --tls-client-cert for two-way TLS) | | `--tls-client-cert=""` | When using TLS communication, the path to the certificate to present as a client (not required for TLS) | | `--tls-client-cert-key=""` | When using TLS communication, the path to the certificate key to use with the client certificate (not required for TLS) | | `--exoscale-apienv="api"` | When using Exoscale provider, specify the API environment (optional) | | `--exoscale-apizone="ch-gva-2"` | When using Exoscale provider, specify the API Zone (optional) | | `--exoscale-apikey=""` | Provide your API Key for the Exoscale provider | | `--exoscale-apisecret=""` | Provide your API Secret for the Exoscale provider | | `--rfc2136-host=` | When using the RFC2136 provider, specify the host of the DNS server (optionally specify multiple times when using --rfc2136-load-balancing-strategy) | | `--rfc2136-port=0` | When using the RFC2136 provider, specify the port of the DNS server | | `--rfc2136-zone=RFC2136-ZONE` | When using the RFC2136 provider, specify zone entry of the DNS server to use (can be specified multiple times) | | `--[no-]rfc2136-create-ptr` | When using the RFC2136 provider, enable PTR management | | `--[no-]rfc2136-insecure` | When using the RFC2136 provider, specify whether to attach TSIG or not (default: false, requires --rfc2136-tsig-keyname and rfc2136-tsig-secret) | | `--rfc2136-tsig-keyname=""` | When using the RFC2136 provider, specify the TSIG key to attached to DNS messages (required when --rfc2136-insecure=false) | | `--rfc2136-tsig-secret=""` | When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false) | | `--rfc2136-tsig-secret-alg=""` | When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false) | | `--[no-]rfc2136-tsig-axfr` | When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false) | | `--rfc2136-min-ttl=0s` | When using the RFC2136 provider, specify minimal TTL (in duration format) for records. This value will be used if the provided TTL for a service/ingress is lower than this | | `--[no-]rfc2136-gss-tsig` | When using the RFC2136 provider, specify whether to use secure updates with GSS-TSIG using Kerberos (default: false, requires --rfc2136-kerberos-realm, --rfc2136-kerberos-username, and rfc2136-kerberos-password) | | `--rfc2136-kerberos-username=""` | When using the RFC2136 provider with GSS-TSIG, specify the username of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true) | | `--rfc2136-kerberos-password=""` | When using the RFC2136 provider with GSS-TSIG, specify the password of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true) | | `--rfc2136-kerberos-realm=""` | When using the RFC2136 provider with GSS-TSIG, specify the realm of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true) | | `--rfc2136-batch-change-size=50` | When using the RFC2136 provider, set the maximum number of changes that will be applied in each batch. | | `--[no-]rfc2136-use-tls` | When using the RFC2136 provider, communicate with name server over tls | | `--[no-]rfc2136-skip-tls-verify` | When using TLS with the RFC2136 provider, disable verification of any TLS certificates | | `--rfc2136-load-balancing-strategy=disabled` | When using the RFC2136 provider, specify the load balancing strategy (default: disabled, options: random, round-robin, disabled) | | `--transip-account=""` | When using the TransIP provider, specify the account name (required when --provider=transip) | | `--transip-keyfile=""` | When using the TransIP provider, specify the path to the private key file (required when --provider=transip) | | `--pihole-server=""` | When using the Pihole provider, the base URL of the Pihole web server (required when --provider=pihole) | | `--pihole-password=""` | When using the Pihole provider, the password to the server if it is protected | | `--[no-]pihole-tls-skip-verify` | When using the Pihole provider, disable verification of any TLS certificates | | `--pihole-api-version="5"` | When using the Pihole provider, specify the pihole API version (default: 5, options: 5, 6) | | `--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) | | `--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! | | `--txt-wildcard-replacement=""` | When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional) | | `--[no-]txt-encrypt-enabled` | When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled) | | `--txt-encrypt-aes-key=""` | When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true) | | `--migrate-from-txt-owner=""` | Old txt-owner-id that needs to be overwritten (default: default) | | `--dynamodb-region=""` | When using the DynamoDB registry, the AWS region of the DynamoDB table (optional) | | `--dynamodb-table="external-dns"` | When using the DynamoDB registry, the name of the DynamoDB table (default: "external-dns") | | `--txt-cache-interval=0s` | The interval between cache synchronizations in duration format (default: disabled) | | `--interval=1m0s` | The interval between two consecutive synchronizations in duration format (default: 1m) | | `--min-event-sync-interval=5s` | The minimum interval between two consecutive synchronizations triggered from kubernetes events in duration format (default: 5s) | | `--[no-]once` | When enabled, exits the synchronization loop after the first iteration (default: disabled) | | `--[no-]dry-run` | When enabled, prints DNS record changes rather than actually performing them (default: disabled) | | `--[no-]events` | When enabled, in addition to running every interval, the reconciliation loop will get triggered when supported sources change (default: disabled) | | `--min-ttl=0s` | Configure global TTL for records in duration format. This value is used when the TTL for a source is not set or set to 0. (optional; examples: 1m12s, 72s, 72) | | `--log-format=text` | The format in which log messages are printed (default: text, options: text, json) | | `--metrics-address=":7979"` | Specify where to serve the metrics and health check endpoint (default: :7979) | | `--log-level=info` | Set the level of logging. (default: info, options: panic, debug, info, warning, error, fatal) | | `--webhook-provider-url="http://localhost:8888"` | The URL of the remote endpoint to call for the webhook provider (default: http://localhost:8888) | | `--webhook-provider-read-timeout=5s` | The read timeout for the webhook provider in duration format (default: 5s) | | `--webhook-provider-write-timeout=10s` | The write timeout for the webhook provider in duration format (default: 10s) | | `--[no-]webhook-server` | When enabled, runs as a webhook server instead of a controller. (default: false). | | `--[no-]combine-fqdn-annotation` | Combine FQDN template and Annotations instead of overwriting (default: false) | | `--fqdn-template=""` | A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN. | | `--target-template=""` | A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Accepts comma separated list for multiple targets. | | `--fqdn-target-template=""` | A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Accepts comma separated list for multiple pairs. | | `--provider=provider` | The DNS provider where the DNS records will be created (required, options: akamai, alibabacloud, aws, aws-sd, azure, azure-dns, azure-private-dns, civo, cloudflare, coredns, dnsimple, exoscale, gandi, godaddy, google, inmemory, linode, ns1, oci, ovh, pdns, pihole, plural, rfc2136, scaleway, skydns, transip, webhook) | | `--source=source` | The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, contour-httpproxy, gloo-proxy, fake, connector, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy, unstructured) | ================================================ FILE: docs/initial-design.md ================================================ # Proposal: Design of External DNS ## Background [Project proposal](https://groups.google.com/forum/#!searching/kubernetes-dev/external$20dns%7Csort:relevance/kubernetes-dev/2wGQUB0fUuE/9OXz01i2BgAJ) [Initial discussion](https://docs.google.com/document/d/1ML_q3OppUtQKXan6Q42xIq2jelSoIivuXI8zExbc6ec/edit#heading=h.1pgkuagjhm4p) This document describes the initial design proposal. External DNS is purposed to fill the existing gap of creating DNS records for Kubernetes resources. While there exist alternative solutions, this project is meant to be a standard way of managing DNS records for Kubernetes. The current project is a fusion of the following projects and driven by its maintainers: 1. [Kops DNS Controller](https://github.com/kubernetes/kops/tree/HEAD/dns-controller) 2. [Mate](https://github.com/linki/mate) 3. [wearemolecule/route53-kubernetes](https://github.com/wearemolecule/route53-kubernetes) ## Example use case User runs `kubectl create -f ingress.yaml`, this will create an ingress as normal. Typically the user would then have to manually create a DNS record pointing the ingress endpoint If the external-dns controller is running on the cluster, it could automatically configure the DNS records instead, by observing the host attribute in the ingress object. ## Goals 1. Support AWS Route53 and Google Cloud DNS providers 2. DNS for Kubernetes services(type=Loadbalancer) and Ingress 3. Create/update/remove records as according to Kubernetes resources state 4. It should address main requirements and support main features of the projects mentioned above ## Design ### Extensibility New cloud providers should be easily pluggable. Initially only AWS/Google platforms are supported. However, in the future we are planning to incorporate CoreDNS and Azure DNS as possible DNS providers ### Configuration DNS records will be automatically created in multiple situations: 1. Setting `spec.rules.host` on an ingress object. 2. Setting `spec.tls.hosts` on an ingress object. 3. Adding the annotation `external-dns.alpha.kubernetes.io/hostname` on an ingress object. 4. Adding the annotation `external-dns.alpha.kubernetes.io/hostname` on a `type=LoadBalancer` service object. ### Annotations Record configuration should occur via resource annotations. Supported annotations: | Annotations | | |-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Tag | external-dns.alpha.kubernetes.io/controller | | Description | Tells a DNS controller to process this service. This is useful when running different DNS controllers at the same time (or different versions of the same controller). | | Details | The v1 implementation of dns-controller would look for service annotations `dns-controller` and `dns-controller/v1` but not for `mate/v1` or `dns-controller/v2` | | Default | dns-controller | | Example | dns-controller/v1 | | Required | false | | --- | --- | | Tag | external-dns.alpha.kubernetes.io/hostname | | Description | Fully qualified name of the desired record | | Default | none | | Example | foo.example.org | | Required | Only for services. Ingress hostname is retrieved from `spec.rules.host` meta data on ingress | ### Compatibility External DNS should be compatible with annotations used by three above mentioned projects. The idea is that resources created and tagged with annotations for other projects should continue to be valid and now managed by External DNS. **Mate** Mate does not require services/ingress to be tagged. Therefore, it is not safe to run both Mate and External-DNS simultaneously. The idea is that initial release (?) of External DNS will support Mate annotations, which indicates the hostname to be created. Therefore the switch should be simple. | Annotations | | |-------------|----------------------------------------------| | Tag | zalando.org/dnsname | | Description | Hostname to be registered | | Default | Empty(falls back to template based approach) | | Example | foo.example.org | | Required | false | **route53-kubernetes** It should be safe to run both `route53-kubernetes` and `external-dns` simultaneously. Since `route53-kubernetes` only looks at services with the label `dns=route53` and does not support ingress there should be no collisions between annotations. If users desire to switch to `external-dns` they can run both controllers and migrate services over as they are able. ### Ownership External DNS should be *responsible* for the created records. Which means that the records should be tagged and only tagged records are viable for future deletion/update. It should not mess with pre-existing records created via other means. #### Ownership via TXT records Each record managed by External DNS is accompanied with a TXT record with a specific value to indicate that corresponding DNS record is managed by External DNS and it can be updated/deleted respectively. TXT records are limited to lifetimes of service/ingress objects and are created/deleted once k8s resources are created/deleted. ================================================ FILE: docs/monitoring/index.md ================================================ # Monitoring & Observability Monitoring is a crucial aspect of maintaining the health and performance of your applications. It involves collecting, analyzing, and using information to ensure that your system is running smoothly and efficiently. Effective monitoring helps in identifying issues early, understanding system behavior, and making informed decisions to improve performance and reliability. For `external-dns`, all metrics available for scraping are exposed on the `/metrics` endpoint. The metrics are in the Prometheus exposition format, which is widely used for monitoring and alerting. To access the metrics: ```sh curl https://localhost:7979/metrics ``` In the metrics output, you'll see the help text, type information, and current value of the `external_dns_registry_endpoints_total` counter: ```yml # HELP external_dns_registry_endpoints_total Number of Endpoints in the registry # TYPE external_dns_registry_endpoints_total gauge external_dns_registry_endpoints_total 11 ``` You can configure a locally running [Prometheus instance](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#scrape_config) to scrape metrics from the application. Here's an example prometheus.yml configuration: ```yml scrape_configs: - job_name: external-dns scrape_interval: 10s static_configs: - targets: - localhost:7979 ``` For more detailed information on how to instrument application with Prometheus, you can refer to the [Prometheus Go client library documentation](https://prometheus.io/docs/guides/go-application/). ## What metrics can I get from ExternalDNS and what do they mean? - The project maintain a [metrics page](./metrics.md) with a list of supported custom metrics. - [Go runtime](https://pkg.go.dev/runtime/metrics#hdr-Supported_metrics) metrics also available for scraping. ExternalDNS exposes 3 types of metrics: Sources, Registry errors and Cache hits. `Source`s are mostly Kubernetes API objects. Examples of `source` errors may be connection errors to the Kubernetes API server itself or missing RBAC permissions. It can also stem from incompatible configuration in the objects itself like invalid characters, processing a broken fqdnTemplate, etc. `Registry` errors are mostly Provider errors, unless there's some coding flaw in the registry package. Provider errors often arise due to accessing their APIs due to network or missing cloud-provider permissions when reading records. When applying a changeset, errors will arise if the changeset applied is incompatible with the current state. In case of an increased error count, you could correlate them with the `http_request_duration_seconds{handler="instrumented_http"}` metric which should show increased numbers for status codes 4xx (permissions, configuration, invalid changeset) or 5xx (apiserver down). You can use the host label in the metric to figure out if the request was against the Kubernetes API server (Source errors) or the DNS provider API (Registry/Provider errors). ## Owner Mismatch Metrics The `external_dns_registry_skipped_records_owner_mismatch_per_sync` metric tracks DNS records that were skipped during synchronization because they are owned by a different ExternalDNS instance. This is useful for detecting ownership conflicts in multi-tenant or multi-instance deployments. The metric includes the following labels: | Label | Description | |:----------------|:-------------------------------------------------| | `record_type` | DNS record type (A, AAAA, CNAME, etc.) | | `owner` | The owner ID of the current ExternalDNS instance | | `foreign_owner` | The owner ID found on the existing record | | `domain` | The naked/apex domain (e.g., "example.com") | **Note:** The `domain` label uses the naked/apex domain rather than the full FQDN to prevent metric cardinality explosion. With thousands of subdomains under one apex domain, using full FQDNs would create excessive metric series. ## Metrics Best Practices When scraping ExternalDNS metrics, consider the following best practices: ### Cardinality Management - **Vector metrics** (those with labels like `record_type`, `domain`) can generate multiple time series. Monitor your Prometheus storage and memory usage accordingly. - The `domain` label on owner mismatch metrics is intentionally limited to apex domains to bound cardinality. - Use recording rules to pre-aggregate high-cardinality metrics if you only need totals. ### Recommended Scrape Interval - A scrape interval of 10-30 seconds is typically sufficient for ExternalDNS metrics. - Align your scrape interval with ExternalDNS's sync interval (`--interval` flag) for meaningful data. ### Alerting Recommendations Consider alerting on: - `external_dns_source_errors_total` or `external_dns_registry_errors_total` increasing - indicates connectivity or permission issues. - `external_dns_controller_last_sync_timestamp_seconds` not updating - indicates the sync loop may be stuck. - `external_dns_registry_skipped_records_owner_mismatch_per_sync` non-zero - indicates ownership conflicts that may need investigation. ## Resources - [Prometheus Instrumentation](https://prometheus.io/docs/practices/instrumentation/) - [Prometheus Alerting Best Practices](https://prometheus.io/docs/practices/alerting/) - [Prometheus Recording Rules](https://prometheus.io/docs/practices/rules/) - [Grafana: How to Manage High Cardinality Metrics](https://grafana.com/blog/2022/02/15/what-are-cardinality-spikes-and-why-do-they-matter/) ================================================ FILE: docs/monitoring/metrics.md ================================================ --- tags: - metrics - autogenerated --- # Available Metrics All metrics available for scraping are exposed on the `/metrics` endpoint. The metrics are in the Prometheus exposition format. To access the metrics: ```sh curl https://localhost:7979/metrics ``` ## Supported Metrics > Full metric name is constructed as follows: > `external_dns__` | Name | Metric Type | Subsystem | Help | |:----------------------------------------|:------------|:-----------------|:---------------------------------------------------------------------------------------------------------------------------------------------------| | build_info | Gauge | | A metric with a constant '1' value labeled with 'version' and 'revision' of external_dns and the 'go_version', 'os' and the 'arch' used the build. | | consecutive_soft_errors | Gauge | controller | Number of consecutive soft errors in reconciliation loop. | | last_reconcile_timestamp_seconds | Gauge | controller | Timestamp of last attempted sync with the DNS provider | | last_sync_timestamp_seconds | Gauge | controller | Timestamp of last successful sync with the DNS provider | | no_op_runs_total | Counter | controller | Number of reconcile loops ending up with no changes on the DNS provider side. | | verified_records | Gauge | controller | Number of DNS records that exists both in source and registry (vector). | | request_duration_seconds | Summaryvec | http | The HTTP request latencies in seconds. | | cache_apply_changes_calls | Counter | provider | Number of calls to the provider cache ApplyChanges. | | cache_records_calls | Counter | provider | Number of calls to the provider cache Records list. | | endpoints_total | Gauge | registry | Number of Endpoints in the registry | | errors_total | Counter | registry | Number of Registry errors. | | records | Gauge | registry | Number of registry records partitioned by label name (vector). | | skipped_records_owner_mismatch_per_sync | Gauge | registry | Number of records skipped with owner mismatch for each record type, owner mismatch ID and domain (vector). | | endpoints_total | Gauge | source | Number of Endpoints in all sources | | errors_total | Counter | source | Number of Source errors. | | records | Gauge | source | Number of source records partitioned by label name (vector). | | adjustendpoints_errors_total | Gauge | webhook_provider | Errors with AdjustEndpoints method | | adjustendpoints_requests_total | Gauge | webhook_provider | Requests with AdjustEndpoints method | | applychanges_errors_total | Gauge | webhook_provider | Errors with ApplyChanges method | | applychanges_requests_total | Gauge | webhook_provider | Requests with ApplyChanges method | | records_errors_total | Gauge | webhook_provider | Errors with Records method | | records_requests_total | Gauge | webhook_provider | Requests with Records method | ## Available Go Runtime Metrics > The following Go runtime metrics are available for scraping. Please note that they may change over time and they are OS dependent. | Name | |:-------------------------------------| | go_gc_duration_seconds | | go_gc_gogc_percent | | go_gc_gomemlimit_bytes | | go_goroutines | | go_info | | go_memstats_alloc_bytes | | go_memstats_alloc_bytes_total | | go_memstats_buck_hash_sys_bytes | | go_memstats_frees_total | | go_memstats_gc_sys_bytes | | go_memstats_heap_alloc_bytes | | go_memstats_heap_idle_bytes | | go_memstats_heap_inuse_bytes | | go_memstats_heap_objects | | go_memstats_heap_released_bytes | | go_memstats_heap_sys_bytes | | go_memstats_last_gc_time_seconds | | go_memstats_mallocs_total | | go_memstats_mcache_inuse_bytes | | go_memstats_mcache_sys_bytes | | go_memstats_mspan_inuse_bytes | | go_memstats_mspan_sys_bytes | | go_memstats_next_gc_bytes | | go_memstats_other_sys_bytes | | go_memstats_stack_inuse_bytes | | go_memstats_stack_sys_bytes | | go_memstats_sys_bytes | | go_sched_gomaxprocs_threads | | go_threads | | process_cpu_seconds_total | | process_max_fds | | process_network_receive_bytes_total | | process_network_transmit_bytes_total | | process_open_fds | | process_resident_memory_bytes | | process_start_time_seconds | | process_virtual_memory_bytes | | process_virtual_memory_max_bytes | ================================================ FILE: docs/overrides/partials/copyright.html ================================================ ================================================ FILE: docs/proposal/001-leader-election.md ================================================ ```yaml --- title: leader election proposal version: 0.15.1 authors: @ivankatliarchuk creation-date: 2025-01-30 status: not-planned --- ``` # Leader Election In Kubernetes, **leader election** is a mechanism used by applications, controllers, or distributed systems to designate one instance or node as the "leader" that is responsible for managing specific tasks, while others operate as followers or standbys. This ensures coordinated and fault-tolerant operations in highly available systems. - [Kubernetes Coordinated Leader Election](https://kubernetes.io/docs/concepts/cluster-administration/coordinated-leader-election/) - [Kubernetes Concepts: Leases](https://kubernetes.io/docs/concepts/architecture/leases/) ## **Leader Election in Kubernetes** The leader election mechanism implemented in Go code relies on Kubernetes coordination features, specifically Lease object in the `coordination.k8s.io` API Group. Lease locks provide a way to acquire a lease on a shared resource, which can be used to determine the leader among a group of nodes. ***Leader Election Sequence Diagram*** ```mermaid sequenceDiagram participant R1 as Replica 1 (Leader) participant LR as Lock Resource participant R2 as Replica 2 (Standby) participant R3 as Replica 3 (Standby) R1->>LR: Update Lock Resource Note over LR: currentLeader: R1
timeStamp: 12:21
leaseDuration: 10s loop Every polling period R2->>LR: Poll leader status LR-->>R2: Return lock info R3->>LR: Poll leader status LR-->>R3: Return lock info end Note over R2,R3: Replicas remain on standby
as long as leader is active ``` ***Leader Election Flow*** ```mermaid graph TD subgraph Active Replica A[Replica 1] end subgraph Kubernetes Resource Lock A["fa:fa-server Replica 1"] --> |Hold The Lock| C@{ label: "Lock" } end subgraph Standby Replicas D["fa:fa-server Replica 2"] -->|Poll| C E["fa:fa-server Replica 3"] -->|Poll| C["fa:fa-lock Lock"] end style C color:#8C52FF,fill:#A6A6A6 style A color:#8C52FF,fill:#00BF63 style D color:#000000,fill:#FFDE59 style E color:#000000,fill:#FFDE59 ``` ***How Leader Is Elected*** ```mermaid flowchart TD A[Start Leader Election] -->|Replica 1 Becomes Leader| B(Update Lock Resource) B --> C{Is Leader Active?} C -->|Yes| D[Replicas 2 & 3 Poll Leader Status] C -->|No| E[Trigger New Election] E -->|New Leader Found| F[Replica X Becomes Leader] E -->|No Leader| G[Retry Election] F --> B G --> C D --> C ``` ### Enable Leader Election Minimum supported Kubernetes version is `v1.26`. > Currently, this feature is "opt-in". The `--enable-leader-election` flag must be explicitly provided to activate it in the service. | **Flag** | **Description** | |:---------------------------|:------------------------------------------------------| | `--enable-leader-election` | This flag is required to enable leader election logic | ```yml args: --registry=txt \ --source=fake \ --enable-leader-election ``` ## **How Leader Election Works in Kubernetes** 1. **Lease API**: - Kubernetes provides a built-in `Lease` object in the `coordination.k8s.io/v1` API group, specifically designed for leader election. - The leader writes a lease object with metadata (such as its identity and timestamp) to signal that it is the current leader. 2. **Election Process**: - All participating pods (or nodes) periodically check for the lease. - The lease contains details of the current leader's identity (e.g., a pod name). - If the lease expires or is not renewed, other contenders can try to acquire leadership by writing their identity into the lease object. 3. **Heartbeat (Lease Renewal)**: - The current leader must periodically update the lease to retain leadership. - If the leader fails to renew the lease within the configured timeout, leadership is relinquished, and another instance can take over. --- ### **Key Concepts** - **Lease Duration**: Defines how long the leader is considered valid after the last lease renewal. Short lease durations result in faster failovers but higher contention and potential performance impact. - **Leader Identity**: Usually the name or ID of the pod that holds the leadership role. - **Backoff and Contention**: Followers typically wait and retry with a backoff period to avoid overwhelming the system when a leader is lost. --- ### **Why Leader Election is Important** Leader election ensures that: - **High Availability**: Fail-over to a new leader ensures availability even if the current leader goes down. - **Data Consistency**: Only one leader acts on critical tasks, preventing duplicate work or conflicting updates. - **Workload Distribution**: Secondary replicas can be on standby, reducing resource contention. --- ### **Use Cases** Leader election functionality is critical for building reliable, fault-tolerant, and scalable applications on Kubernetes. - **Cluster Upgrades**: Leader election ensures smooth cluster upgrades by designating one instance as responsible for orchestrating upgrades or managing specific components during the process. By preventing multiple instances from making changes concurrently, it avoids conflicts and reduces downtime, ensuring consistency across the cluster. - **Workload Running on Spot Instances**: For workloads running on cost-effective but ephemeral spot instances, leader election is crucial for resiliency. When a spot instance running the leader is preempted, the failover process enables a standby instance to seamlessly take over leadership, ensuring continued execution of critical tasks. - **Requirement for Disaster Recovery**: In disaster recovery scenarios, leader election provides fault tolerance by allowing another instance to take over when the primary leader becomes unavailable. This guarantees operational continuity even in the face of unexpected failures, supporting robust disaster recovery strategies. - **High Availability (HA) Scenarios**: In highly available systems, leader election ensures that a single active leader manages essential processes or state, while backups remain ready to step in instantly in case of failure. This minimizes recovery time objectives (RTO) and eliminates single points of failure. - **Enhanced Reliability in Distributed Systems**: Incorporating leader election into your distributed system enhances its overall reliability. It avoids the pitfalls of uncoordinated task execution, providing deterministic behavior and ensuring only one instance manages critical tasks at any given time. - **Conflict Prevention**: Leader election serves as a guard against conflicts arising from multiple instances attempting to execute the same tasks. By ensuring that only the elected leader acts on shared resources or processes, it prevents data corruption, inconsistencies, and wasted computational effort. ================================================ FILE: docs/proposal/002-internal-ipv6-handling-rollback.md ================================================ ```yaml --- title: "Proposal: Rollback IPv6 internal Node IP exposure" version: if applicable authors: @ivankatliarchuk, @szuecs, @mloiseleur creation-date: 2025-01-01 status: implemented --- ``` # Introduce Feature Flag for IPv6 Internal Node IP Handling in ''external-dns'' and Change the behavior ## Summary This proposal aims to introduce a feature flag in 'external-dns' to control the handling of IPv6 internal node IPs. In the current version, the feature flag will default to the existing behavior. In the next `minor` or `minor+N` version, the default behavior will be reversed, encouraging users to adopt the new behavior while providing a transition period. ## Motivation The discussion in [issue#4566](https://github.com/kubernetes-sigs/external-dns/issues/4566) and the subsequent [pr#4574](https://github.com/kubernetes-sigs/external-dns/pull/4574) and [pr#4808](https://github.com/kubernetes-sigs/external-dns/pull/4808) highlighted concerns regarding the treatment of IPv6 internal node IPs. To address these concerns without causing immediate disruption, a feature flag will allow users to opt-out the current behavior, providing flexibility during the transition. ## Goals - Introduce feature to toggle the handling of IPv6 internal node IPs ## Non-Goals - ***Propose/Add an annotation for this specific use case*** - Provide support for `external-dns.alpha.kubernetes.io/expose-internal-ipv6` in follow-up releases. - Managing dual annotation and flag may introduce complexity. ## Proposal - ***Introduce Feature Flag*** - Add a feature flag, e.g., `--expose-internal-ipv6=true`, to control the handling of IPv6 internal node IPs. - In the current version, this flag will default to `true`, maintaining the existing behavior. - ***Flip Default Behavior in Next Minor Version*** - In the subsequent minor release, change the default value of `--expose-internal-ipv6` to `false`, adopting the new behavior by default. - Users can still override this behavior by explicitly setting the flag as needed. Proposed Changes in `source/node.go` file. ```go // IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well. if addr.Type == v1.NodeInternalIP && ns.exposeInternalIP && ... { ipv6Addresses = append(ipv6Addresses, addr.Address) } ``` ## User Stories - **As a cluster Operator or Administrator**, I want to control the handling of IPv6 internal node IPs to align with defined network topology and configuration. - **As a SecDevOps**, I want to ensure that `external-dns` does not expose internal IPv6 node addresses via public DNS records, so that I can prevent unintended data leaks and reduce the attack surface of my Kubernetes cluster. - **As a SecDevOps**, I want to use a feature flag to selectively enable or disable the new IPv6 behavior in `external-dns`, so that I can evaluate its security impact before it becomes the default setting in future releases. - **As a SecDevOps**, I want to use a feature flag to selectively enable or disable the new IPv6 behavior in `external-dns`, so that I can detect misconfigurations, act on potential security incidents, and ensure compliance with security policies. ## Implementation Steps - Code Changes: - Implement the feature flag in the 'external-dns' codebase to toggle the handling of IPv6 internal node IPs. - Documentation: - Update the 'external-dns' documentation to include information about the feature flag, its purpose, and usage examples. ## Drawbacks - Introducing a feature flag adds complexity to the configuration and codebase. - Changing default behavior in a future release may still cause issues for users who are unaware of the change. ## Alternatives - ***Immediate Behavior Change*** - Directly change the behavior without a feature flag, which could lead to unexpected issues for users. - ***No Change*** - Maintain the current behavior, potentially leaving the concerns unaddressed. - Users may not be able to update an `external-dns` version due to security, compliance or any other concerns. ================================================ FILE: docs/proposal/003-dnsendpoint-graduation-to-beta.md ================================================ ```yaml --- title: "Proposal: Defining a path to Beta for DNSEndpoint API" version: v1alpha1 authors: @ivankatliarchuk, @raffo, @szuecs creation-date: 2025-02-09 status: approved --- ``` # Proposal: Defining a path to Beta for DNSEndpoint API ## Summary The `DNSEndpoint` API in Kubernetes SIGs `external-dns` is currently in alpha. To ensure its stability and readiness for production environments, we propose defining and agreeing upon the necessary requirements for its graduation to beta. By defining clear criteria, we aim to ensure stability, usability, and compatibility with the broader Kubernetes ecosystem. On completions of all this items, we should be in the position to graduate `DNSEndpoint` to `v1beta`. ## Motivation The DNSEndpoint API is a crucial component of the ExternalDNS project, allowing users to manage DNS records dynamically. Currently, it remains in the alpha stage, limiting its adoption due to potential instability and lack of guaranteed backward compatibility. By advancing to beta, we can provide users with a more reliable API and encourage wider adoption with confidence in its long-term viability and support. ### Goals - Define the necessary requirements for `DNSEndpoint` API to reach beta status. - Improve API stability, usability, and documentation. - Improve test coverage, automate documentation creation, and validation mechanisms. - Ensure backward compatibility and migration strategies from alpha to beta. - Collect and incorporate feedback from existing users to refine the API. - Address any identified issues or limitations in the current API design. ### Non-Goals - This proposal does not cover the graduation of ExternalDNS itself to a stable release. - Making `DNSEndpoint` a stable (GA) API at this stage. - It does not include implementation details for specific DNS providers. - It does not introduce new functionality beyond stabilizing the DNSEndpoint API. - Redesigning the API from scratch. - Introducing breaking changes that would require significant refactoring for existing users. ## Proposal The proposal aims to formalize the promotion process by addressing API design, user needs, and implementation details. To graduate the `DNSEndpoint` API to beta, we propose the following actions: 1. Capture feedback from the community on missing functionality for DNSEndpoint CRD - In a form of Github issue, pin the issue to the project - Link all CRD related issues to it 2. Refactor `endpoint` folder, move away `api/crd` related stuff to `apis/ folder` 3. Documentation for API to be generated automatically with test coverage, similar to `docs/flags.md` 4. APIs and CRDs discoverable. [doc.crds.dev](https://doc.crds.dev/github.com/kubernetes-sigs/external-dns). Example [crossplane](https://doc.crds.dev/github.com/crossplane/crossplane@v0.10.0) 5. Review and change .status object such that people can debug and monitor DNSEndpoint object behavior. 6. Introduce metrics related to DNSEndpoint CRD - Number of CRDs discovered - Number of CRDs by status success|fail Proposed folder structure for `apis`. Examples - [gateway-api](https://github.com/kubernetes-sigs/gateway-api/tree/main/apis) ***Multiple APIs under same version*** ```yml ├── apis │ ├── v1alpha │ │ ├── util/validation │ │ ├── doc.go │ │ └── zz_generated.***.go │ ├── v1beta # outside of scope currently, just an example │ │ ├── util/validation │ │ ├── doc.go │ │ └── zz_generated.***.go │ ├── v1 # outside of scope currently, just an example │ │ ├── util/validation │ │ ├── doc.go │ │ └── zz_generated.***.go ``` Or similar folder structure for `apis`. Examples - [cert-manager](https://github.com/cert-manager/cert-manager/tree/master/pkg/apis) ***APIs versioned independently*** ```yml ├── apis │ ├── dnsendpoint │ │ ├── v1alpha │ │ │ ├── util/validation │ │ │ ├── doc.go │ │ │ └── zz_generated.***.go │ │ ├── v1beta # outside of scope currently, just an example │ │ │ ├── util/validation │ │ │ ├── doc.go │ │ │ └── zz_generated.***.go │ │ ├── v1 # outside of scope currently, just an example │ │ │ ├── util/validation │ │ │ ├── doc.go │ │ │ └── zz_generated.***.go │ ├── dnsentry │ │ ├── v1alpha ``` ### User Stories #### Story 1: Cluster Operator/Admin Managing External DNS *As a cluster operator or administrator*, I want a stable `DNSEndpoint` API to reliably manage DNS records within Kubernetes so that I can ensure consistent and automated DNS resolution for my services. #### Story 2: Developers Integrating External DNS *As a developer*, I want a well-documented `DNSEndpoint` API that allows me to programmatically define and manage DNS records without worrying about breaking changes. #### Story 3: Cloud-Native Deployments *As a SRE*, I need a tested and validated `DNSEndpoint` API that integrates seamlessly with cloud-native networking services, ensuring high availability and scalability. #### Story 4: Platform Engineer *As a platform engineer*, I want stronger validation and defaulting so that I can reduce misconfigurations and operational overhead. ### API The DNSEndpoint API should provide a robust Custom Resource Definition (CRD) with well-defined fields and validation. #### DNSEndpoint - [ ] DNSEndpoint do not have any changes from v1alpha1. - [ ] DNSEndpoint to have changes from v1alpha1. `TBD` ```yml apiVersion: externaldns.k8s.io/v1beta1 kind: DNSEndpoint metadata: name: example-endpoint spec: endpoints: - dnsName: "example.com" recordType: "A" targets: - "192.168.1.1" ttl: 300 - dnsName: "www.example.com" recordType: "CNAME" targets: - "example.com" ``` ### Behavior How should the new CRD or feature behave? Are there edge cases? ### Drawbacks - Transitioning to beta may require deprecating certain alpha features that are deemed unstable. - Increased maintenance effort to ensure stability and backward compatibility. - Users of the alpha API may need to adjust their configurations if breaking changes are introduced. - Additional maintenance and support burden for the `external-dns` maintainers. ## Alternatives 1. **Remain in Alpha**: The DNSEndpoint API could remain in alpha indefinitely, but this would discourage adoption and limit its reliability. - Pros: No immediate changes or migration concerns. - Cons: Lack of progress discourages adoption, and users may seek alternative solutions. 2. **Graduate Directly to GA**: Skipping the beta phase could accelerate stability, but it would limit the opportunity for community feedback and refinement. 3. **Introduce a New API Version**: Instead of modifying the existing API, a new version (e.g., `v2alpha1`) could be introduced, allowing gradual migration. - Pros: Allowing gradual migration like `v1alpha1` -> `v2alpha1` -> `v1beta` - Cons: This approach would require maintaining multiple versions simultaneously. 4. **Redesign the API Before Graduation** - Pros: Provides an opportunity to fix any fundamental design flaws before moving to beta. - Cons: Increases complexity, delays the beta release, and may introduce unnecessary work for existing users. 5. **Deprecate DNSEndpoint and Rely on External Solutions or Annotations** - Pros: Potentially reduces the maintenance burden on the Kubernetes SIG. - Cons: Forces users to migrate to third-party solutions or away from CRDs, reducing the cohesion of external-dns within Kubernetes. ================================================ FILE: docs/proposal/004-gateway-api-annotation-placement.md ================================================ ```yaml --- title: "Gateway API Annotation Placement Clarity" version: v1alpha1 authors: "@lexfrei" creation-date: 2025-10-23 status: provisional --- ``` # Gateway API Annotation Placement Clarity ## Table of Contents - [Summary](#summary) - [Motivation](#motivation) - [Goals](#goals) - [Non-Goals](#non-goals) - [Proposal](#proposal) - [User Stories](#user-stories) - [Current Behavior](#current-behavior) - [Proposed Solutions](#proposed-solutions) - [Drawbacks](#drawbacks) - [Alternatives](#alternatives) ## Summary The [annotations documentation](https://kubernetes-sigs.github.io/external-dns/latest/docs/annotations/annotations/) indicates that Gateway API sources support various annotations, but it does not clearly specify which Kubernetes resource (Gateway vs HTTPRoute/GRPCRoute/TLSRoute/etc.) these annotations should be placed on. This ambiguity leads to user confusion and misconfigurations. This proposal aims to: 1. **Short-term**: Improve documentation to explicitly clarify annotation placement 2. **Long-term**: Consider implementing annotation inheritance from Gateway to Routes ## Motivation Users frequently misconfigure annotations when using Gateway API sources because the current documentation uses "Gateway" as the source name in the annotation support table, which is ambiguousit refers to gateway-api sources generically, not the Gateway resource specifically. ### Current Implementation Behavior Based on the source code ([source/gateway.go](https://github.com/kubernetes-sigs/external-dns/blob/master/source/gateway.go)): **Gateway resource annotations:** - `external-dns.alpha.kubernetes.io/target` - read from Gateway ([line ~380](https://github.com/kubernetes-sigs/external-dns/blob/master/source/gateway.go#L380)) **Route resource annotations (HTTPRoute, GRPCRoute, TLSRoute, TCPRoute, UDPRoute):** - `external-dns.alpha.kubernetes.io/hostname` - read from Route - `external-dns.alpha.kubernetes.io/ttl` - read from Route - `external-dns.alpha.kubernetes.io/controller` - read from Route - **Provider-specific annotations** (e.g., `cloudflare-proxied`, `aws/*`, `scw/*`, etc.) - read from Route ([line ~242](https://github.com/kubernetes-sigs/external-dns/blob/master/source/gateway.go#L242)) This separation aligns with Gateway API architecture: - **Gateway** = infrastructure layer (IP addresses, listeners, load balancers) - **Routes** = application layer (DNS records, routing rules, hostnames) However, users expect provider-specific annotations to work on Gateway (similar to how `target` works), leading to silent failures. ### Goals - Clarify annotation placement in documentation to prevent user confusion - Provide practical examples for common providers (Cloudflare, AWS, Scaleway) - Define a clear, documented contract for where each annotation type should be placed - Reduce support burden from repeated misconfigurations ### Non-Goals - This proposal does not address the broader annotation standardization effort discussed in [PR #5080](https://github.com/kubernetes-sigs/external-dns/pull/5080) - Redesigning the Gateway API source implementation - Changing behavior for non-Gateway sources (Ingress, Service, etc.) - Making breaking changes to existing Gateway API functionality ## Proposal ### User Stories #### Story 1: Platform Engineer with Cloudflare (#5901) *As a platform engineer*, I set up a Gateway with the `external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"` annotation, expecting all DNS records for Routes using this Gateway to be proxied through Cloudflare. However, the annotation is silently ignored, and records are created without proxy, leading to unexpected traffic routing and security issues. **Root cause**: Had to dive into source code to discover that provider-specific annotations are only read from Route resources, not Gateway resources. **Current workaround**: Must copy the `cloudflare-proxied` annotation to every HTTPRoute manually. #### Story 2: User Attempting Route-Specific Targets (#4056) *As a user*, I want to specify different target DNS records for specific hosts while sharing a common Gateway. I added `external-dns.alpha.kubernetes.io/target` annotation on HTTPRoute to override the Gateway's target for one specific host, but it doesn't work - the annotation is ignored on HTTPRoute. **Root cause**: The `target` annotation must be on the Gateway resource, not on Route resources. There's no way to override targets on a per-Route basis. **Outcome**: User had to find alternative workarounds to exclude specific hosts or create separate Gateway resources. ### Current Behavior #### Annotation Placement Matrix | Annotation Type | Gateway Resource | Route Resources (HTTPRoute, GRPCRoute, etc.) | |------------------------------------------------------------|-------------------------|----------------------------------------------| | `target` | **Read from Gateway** | L Ignored | | `hostname` | L Not used | **Read from Route** | | `ttl` | L Not used | **Read from Route** | | `controller` | L Not used | **Read from Route** | | Provider-specific (`cloudflare-proxied`, `aws/*`, `scw/*`) | L Not used | **Read from Route** | #### Code References ```go // source/gateway.go line ~380 // Target annotation is read from Gateway override := annotations.TargetsFromTargetAnnotation(gw.gateway.Annotations) // source/gateway.go line ~242 // Provider-specific annotations are read from Route providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(annots) ``` Where `annots` is derived from the Route's metadata (`meta.Annotations`), not the Gateway. ### Proposed Solutions #### Solution 1: Documentation Improvements (Short-term - Quick Win) **Implementation Status**: Documentation improvements proposed in [PR #5918](https://github.com/kubernetes-sigs/external-dns/pull/5918). **Note**: If Solution 2 (Annotation Merging) is implemented, the documentation from PR #5918 will require updates to reflect the new inheritance behavior. **Changes to `docs/annotations/annotations.md`:** Expand footnote [^4] or add a new section "Gateway API Annotation Placement" with a detailed table: ```markdown ### Gateway API Annotation Placement When using Gateway API sources (gateway-httproute, gateway-grpcroute, etc.), annotations must be placed on specific resources: | Annotation | Placement | Example Resource | |------------|-----------|------------------| | `target` | Gateway | `kind: Gateway` | | `hostname` | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. | | `ttl` | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. | | `controller` | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. | | `cloudflare-proxied` | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. | | `aws-*` (all AWS annotations) | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. | | `scw-*` (all Scaleway annotations) | Route | `kind: HTTPRoute`, `kind: GRPCRoute`, etc. | **Rationale**: The Gateway resource defines infrastructure (IP addresses, listeners), while Routes define application-level DNS records. Therefore, DNS record properties (TTL, provider settings) are configured on Routes. ``` **Changes to `docs/sources/gateway-api.md`:** Add a new section after "Hostnames": ```markdown ## Annotations ### Annotation Placement ExternalDNS reads different annotations from different Gateway API resources: - **Gateway annotations**: Only `external-dns.alpha.kubernetes.io/target` is read from Gateway resources - **Route annotations**: All other annotations (hostname, ttl, provider-specific) are read from Route resources #### Example: Cloudflare Proxied Records ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: my-gateway namespace: default annotations: #  Correct: target annotation on Gateway external-dns.alpha.kubernetes.io/target: "203.0.113.1" spec: gatewayClassName: cilium listeners: - name: https hostname: "*.example.com" protocol: HTTPS port: 443 --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: my-route annotations: #  Correct: provider-specific annotations on HTTPRoute external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" external-dns.alpha.kubernetes.io/ttl: "300" spec: parentRefs: - name: my-gateway namespace: default hostnames: - api.example.com rules: - backendRefs: - name: api-service port: 8080 ``` #### Example: AWS Route53 with Routing Policies ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: aws-gateway annotations: #  Correct: target annotation on Gateway external-dns.alpha.kubernetes.io/target: "alb-123.us-east-1.elb.amazonaws.com" --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: weighted-route annotations: #  Correct: AWS-specific annotations on HTTPRoute external-dns.alpha.kubernetes.io/aws-weight: "100" external-dns.alpha.kubernetes.io/set-identifier: "backend-v1" spec: parentRefs: - name: aws-gateway hostnames: - app.example.com ``` ### Common Mistakes ❌ **Incorrect**: Placing provider-specific annotations on Gateway ```yaml kind: Gateway metadata: annotations: external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" # ❌ Ignored ``` ❌ **Incorrect**: Placing target annotation on HTTPRoute ```yaml kind: HTTPRoute metadata: annotations: external-dns.alpha.kubernetes.io/target: "203.0.113.1" # ❌ Ignored ``` **Implementation effort**: Low **Maintenance burden**: Minimal (documentation only) **User benefit**: Immediate clarity, reduced misconfiguration #### Solution 2: Annotation Inheritance and Merging (Long-term - Feature Enhancement) **Reference Implementation**: [PR #5998](https://github.com/kubernetes-sigs/external-dns/pull/5998) Implement annotation merging logic where: 1. Gateway annotations serve as **defaults** for all Routes attached to that Gateway 2. Route annotations **override** Gateway annotations for specific Routes 3. **All annotations are inheritable**, including `target` — enabling per-Route target overrides **Proposed implementation** (pseudocode): ```go // source/gateway.go - proposed changes func (src *gatewayRouteSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { // ... existing code ... for _, route := range routes { // Merge Gateway and Route annotations // Route annotations take precedence over Gateway annotations gwAnnots := gw.gateway.Annotations rtAnnots := route.meta.Annotations mergedAnnots := mergeAnnotations(gwAnnots, rtAnnots) // Use merged annotations for all annotation processing providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(mergedAnnots) ttl := annotations.TTLFromAnnotations(mergedAnnots, resource) // ... rest of endpoint creation ... } } // Helper function func mergeAnnotations(gateway, route map[string]string) map[string]string { merged := make(map[string]string, len(gateway)+len(route)) // Copy Gateway annotations (defaults) for k, v := range gateway { merged[k] = v } // Route annotations override Gateway defaults for k, v := range route { merged[k] = v } return merged } ``` **Example use case enabled by this approach:** ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: intranet-gateway annotations: # Default target for internal services external-dns.alpha.kubernetes.io/target: "172.16.6.6" # Set default for all Routes using this Gateway external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" external-dns.alpha.kubernetes.io/ttl: "300" --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: internal-api # Inherits: target=172.16.6.6, cloudflare-proxied=true, ttl=300 from Gateway spec: parentRefs: - name: intranet-gateway hostnames: - api.internal.example.com --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: public-api annotations: # Override: expose this route to the public internet external-dns.alpha.kubernetes.io/target: "203.0.113.1" # Inherits: cloudflare-proxied=true, ttl=300 from Gateway spec: parentRefs: - name: intranet-gateway hostnames: - api.example.com --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: static-assets annotations: # Override: disable proxying for static content external-dns.alpha.kubernetes.io/cloudflare-proxied: "false" # Inherits: target=172.16.6.6, ttl=300 from Gateway spec: parentRefs: - name: intranet-gateway hostnames: - static.internal.example.com ``` This example demonstrates a common use case: an intranet Gateway where most services are internal (`172.16.6.6`), but specific Routes can be exposed publicly (`203.0.113.1`) by overriding the `target` annotation. **Benefits:** - Reduces configuration duplication - Enables centralized defaults at Gateway level - Maintains flexibility with Route-level overrides - Better matches user mental model (infrastructure defaults + application overrides) - **Solves User Story 2**: Enables per-Route target overrides without creating separate Gateways **Risks:** - Backward compatibility concerns (may change behavior for existing users) - Increased code complexity - Potential for confusion about precedence rules - Need for comprehensive testing across all Gateway API Route types **Mitigation strategies:** - Feature flag to opt-in to new behavior initially - Clear documentation of precedence rules - Extensive test coverage - Migration guide for users **Implementation effort**: Medium **Maintenance burden**: Medium (code + tests + docs) **User benefit**: Significant reduction in configuration overhead ### Drawbacks #### Documentation-Only Solution - Does not address the underlying UX issue (annotation duplication) - Requires users to manually propagate settings across Routes - Still allows silent failures if users misplace annotations #### Annotation Merging Solution - Adds complexity to the codebase - Requires careful consideration of precedence rules - May introduce unexpected behavior changes for existing users - Needs comprehensive testing for edge cases (multiple Gateways, cross-namespace, etc.) - Potential performance impact from annotation merging on every reconciliation ## Alternatives ### Alternative 1: Do Nothing (Status Quo) **Description**: Keep current behavior and documentation as-is. **Pros**: - No implementation effort required - No risk of introducing new bugs - No breaking changes **Cons**: - Users continue to experience confusion and misconfigurations - Increased support burden on maintainers and community - Poor user experience compared to other sources (Ingress supports annotations more intuitively) **Recommendation**: L Not recommended - problem is well-documented and affects user productivity ### Alternative 2: Move All Annotations to Gateway Only **Description**: Refactor source code to read all annotations from Gateway, not Routes. **Pros**: - Simplified mental model (one place for all annotations) - Centralized configuration **Cons**: - **Breaks Gateway API architecture** - Routes define application-layer DNS records, so DNS properties belong on Routes - Cannot have different settings per Route (e.g., different TTLs for api.example.com vs static.example.com) - Loses flexibility that Route-level annotations provide - Requires breaking change to existing implementations **Recommendation**: L Not recommended - violates Gateway API design principles ### Alternative 3: Support Annotations on Both with Strict Validation **Description**: Allow annotations on both Gateway and Route, but error/warn if duplicates exist without clear precedence. **Pros**: - Provides flexibility - Catches configuration errors explicitly **Cons**: - Confusing for users (two valid places to configure) - Requires complex validation logic - Still doesn't solve the "defaults + overrides" use case - More complex to document and support **Recommendation**: � Possible but adds complexity without solving core UX issue ### Alternative 4: Create Dedicated GatewayDNSConfig CRD **Description**: Introduce a new CRD that defines DNS configuration separately from Gateway and Route resources. **Example**: ```yaml apiVersion: externaldns.k8s.io/v1alpha1 kind: GatewayDNSConfig metadata: name: cloudflare-defaults spec: gatewayRef: name: my-gateway defaults: ttl: 300 providerSpecific: - name: cloudflare-proxied value: "true" --- apiVersion: externaldns.k8s.io/v1alpha1 kind: RouteDNSConfig metadata: name: api-route-dns spec: routeRef: kind: HTTPRoute name: api-route overrides: ttl: 60 # Override Gateway default ``` **Pros**: - Clean separation of concerns - Clear precedence model - No annotations needed (type-safe CRDs) - Aligns with Kubernetes resource composition patterns **Cons**: - **Significant implementation effort** (new CRDs, controllers, validation, etc.) - Adds complexity with additional resources to manage - Requires migration from annotation-based approach - Diverges from how other sources work (Ingress, Service use annotations) - May conflict with future annotation standardization efforts **Recommendation**: � Potentially valuable long-term, but scope is too large for this specific issue ### Alternative 5: Wait for Annotation Standardization (PR #5080) **Description**: Defer this work until the broader annotation standardization effort is resolved. **Pros**: - Avoids potentially redundant work - May be addressed as part of larger effort **Cons**: - PR #5080 is not yet ready for review and timeline is uncertain - Users continue to experience issues in the meantime - Documentation improvements are still valuable regardless of standardization outcome **Recommendation**: � Partial - implement documentation improvements now (Solution 1), reconsider annotation merging after standardization is resolved ## Recommendation **Phased approach**: 1. **Immediate (v0.15.0 or next minor)**: Implement Solution 1 (Documentation Improvements) - Low risk, high user value - Can be merged quickly - Addresses immediate pain points 2. **Near-term**: Review and merge Solution 2 (Annotation Merging) - Reference implementation available: [PR #5998](https://github.com/kubernetes-sigs/external-dns/pull/5998) - Includes comprehensive test coverage - Backward compatible (no breaking changes for existing configurations) - Solves User Story 2 (per-Route target overrides) 3. **Future (post-PR #5080 resolution)**: Re-evaluate if additional changes are needed - Assess compatibility with annotation standardization outcomes - Gather user feedback on the annotation inheritance behavior This approach provides immediate relief while keeping options open for more comprehensive solutions in the future. ================================================ FILE: docs/proposal/design-template.md ================================================ ```yaml --- title: New Feature or Deprecation/Removal Proposal version: if applicable authors: you, me creation-date: 2025-01-25 # format ISO 8601: YYYY-MM-DD status: draft|approved|rejected|not-planned|partially-implemented|implemented --- ``` # New Feature or Deprecation/Removal Proposal ## Table of Contents // add it here ## Summary Please provide a summary of this proposal. ## Motivation What is the motivation of this proposal? Why is it useful and relevant? ### Goals What are the goals of this proposal, what's the problem we want to solve? ### Non-Goals What are explicit non-goals of this proposal? ## Proposal How does the proposal look like? ### User Stories How would users use this feature, what are their needs? ### API Please describe the API (CRD or other) and show some examples. ### Behavior How should the new CRD or feature behave? Are there edge cases? ### Drawbacks If we implement this feature, what are drawbacks and disadvantages of this approach? ## Alternatives What alternatives do we have and what are their pros and cons? ================================================ FILE: docs/proposal/multi-target.md ================================================ # Multiple Targets per hostname *(November 2017)* ## Purpose One should be able to define multiple targets (IPs/Hostnames) in the **same** Kubernetes resource object and expect ExternalDNS create DNS record(s) with a specified hostname and all targets. So far the connection between k8s resources (ingress/services) and DNS records were not streamlined. This proposal aims to make the connection explicit, making k8s resources acquire or release certain DNS names. As long as the resource ingress/service owns the record it can have multiple targets enable iff they are specified in the same resource. ## Use cases See https://github.com/kubernetes-sigs/external-dns/issues/239 ## Current behaviour *(as of the moment of writing)* Central piece of enabling multi-target is having consistent and correct behaviour in `plan` component in regards to how endpoints generated from kubernetes resources are mapped to dns records. Current implementation of the `plan` has inconsistent behaviour in the following scenarios, all of which must be resolved before multi-target support can be enabled in the provider implementations: 1. No records registered so far. Two **different** ingresses request same hostname but different targets, e.g. Ingress A: example.com -> 1.1.1.1 and Ingress B: example.com -> 2.2.2.2 * *Current Behaviour*: both are added to the "Create" (records to be created) list and passed to Provider * *Expected Behaviour*: only one (random/ or according to predefined strategy) should be chosen and passed to Provider **NOTE**: while this seems to go against multi-target support, this is done so no other resource can "hijack" already created DNS record. Multi targets are supported only on per single resource basis 2. Now let's say Ingress A was chosen and successfully created, but both ingress A and B are still there. So on next iteration ExternalDNS would see both again in the Desired list. * *Current Behaviour*: DNS record target will change to that of Ingress B. * *Expected Behaviour*: Ingress A should stay unchanged. Ingress B record is not created 3. DNS record for Ingress A was created but its target has changed. Ingress B is still there * *Current Behaviour*: Undetermined behaviour based on which ingress will be parsed last. * *Expected Behaviour*: DNS record should point to the new target specified in A. Ingress B should still be ignored. **NOTE**: both 2. and 3. can be resolved if External DNS is aware which resource has already acquired DNS record 4. Ingress C has multiple targets: 1.1.1.1 and 2.2.2.2 * *Current Behaviour*: Both targets are split into different endpoints and we end up in one of the cases above * *Expected Behaviour*: Endpoint should contain list of targets and treated as one ingress object. ## Requirements and assumptions For this feature to work we have to make sure that: 1. DNS records are now owned by certain ingress/service resources. For External DNS it would mean that TXT records now should store back-reference for the resource this record was created for, i.e. `"heritage=external-dns,external-dns/resource=ingress/default/my-ingress-object-name"` 2. DNS records are updated only: * If owning resource target list has changed * If owning resource record is not found in the desired list (meaning it was deleted), therefore it will now be owned by another record. So its target list will be updated * Changes related to other record properties (e.g. TTL) 4. All of the issues described in `Current Behaviour` sections are resolved Once Create/Update/Delete lists are calculated correctly (this is where conflicts based on requested DNS names are resolved) they are passed to `provider`, where `provider` specific implementation will decide how to convert the structures into required formats. If DNS provider does not (or partially) support multi targets then it is up to the provider to make sure that the change list of records passed to the DNS provider API is valid. **TODO**: explain best strategy. Additionally see https://github.com/kubernetes-sigs/external-dns/issues/258 ## Implementation plan Brief summary of open PRs and what they are trying to address: ### PRs 1. https://github.com/kubernetes-sigs/external-dns/pull/243 - first attempt to add support for multiple targets. It is lagging far behind from tip *what it does*: unfinished attempt to extend `Endpoint` struct, for it to allow multiple targets (essentially `target string -> targets []string`) *action*: evaluate if rebasing makes sense, or we can just close it. 2. https://github.com/kubernetes-sigs/external-dns/pull/261 - attempt to rework `plan` to make it work correctly with multiple targets. *what it does* : attempts to fix issues with `plan` described in `Current Behaviour` section above. Included tests reveal the current problem with `plan` *action*: rebase on default branch and make necessary changes to satisfy requirements listed in this document including back-reference to owning record 3. https://github.com/kubernetes-sigs/external-dns/pull/326 - attempt to add multiple target support. *what it does*: for each pair `DNS Name` + `Record Type` it aggregates **all** targets from the cluster and passes them to Provider. It adds basic support *action*: the `plan` logic will probably needs to be reworked, however the rest concerning support in Providers and extending `Endpoint` struct can be reused. Rebase on default branch and add missing pieces. Depends on `2`. Related PRs: https://github.com/kubernetes-sigs/external-dns/pull/331/files, https://github.com/kubernetes-sigs/external-dns/pull/347/files - aiming at AWS Route53 weighted records. These PRs should be considered after common agreement about the way to address multi-target support is achieved. Related discussion: https://github.com/kubernetes-sigs/external-dns/issues/196 ### How to proceed from here The following steps are needed: 1. Make sure consensus regarding the approach is achieved via collaboration on the current document 2. Notify all PR (see above) authors about the agreed approach 3. Implementation: a. `Plan` is working as expected - either based on #261 above or from scratch. `Plan` should be working correctly regardless of multi-target support b. Extensive testing making sure new `plan` does not introduce any breaking changes c. Change Endpoint struct to support multiple targets - based on #326 - integrate it with new `plan` @sethpollack d. Make sure new endpoint format can still be used in providers which have only partial support for multi targets ~~**TODO**: how ?~~ . This is to be done by simply using first target in the targets list. e. Add support for multi target which are already addressed in #326. It goes alongside c. and can be based on the same PR @sethpollack. New providers added since then should maintain same functionality. 5. Extensive testing on **all** providers before making new release 6. Update all related documentation and explain how multi targets are supported on per provider basis 7. Think of introducing weighted records (see PRs section above) and making them configurable. ## Open questions * Handling cases when ingress/service targets include both hostnames and IPs - postpone this until use cases occurs * "Weighted records scope": https://github.com/kubernetes-sigs/external-dns/issues/196 - this should be considered once multi-target support is implemented ================================================ FILE: docs/providers.md ================================================ # Providers Provider supported configurations | Provider Name | Zone Cache | Dry Run | Default TTL (seconds) | |:--------------|:-----------|:--------|:----------------------| | Akamai | n/a | yes | 600 | | AlibabaCloud | n/a | yes | 600 | | AWS | yes | yes | 300 | | AWSSD | n/a | yes | 300 | | Azure | yes | yes | 300 | | Civo | n/a | yes | n/a | | Cloudflare | n/a | yes | 1 | | CoreDNS | n/a | yes | n/a | | DNSSimple | n/a | yes | 3600 | | Exoscale | n/a | yes | n/a | | Gandi | n/a | no | 600 | | GoDaddy | n/a | yes | 600 | | Google GCP | n/a | yes | 300 | | InMemory | n/a | n/a | n/a | | Linode | n/a | n/a | n/a | | Myra Security | n/a | yes | 300 | | NS1 | n/a | yes | 10 | | OCI | yes | yes | 300 | | OVH | n/a | yes | 0 | | PDNS | n/a | yes | 300 | | PiHole | n/a | yes | n/a | | Plural | n/a | n/a | n/a | | RFC2136 | n/a | yes | n/a | | Scaleway | n/a | n/a | 300 | | Transip | n/a | yes | 60 | | Webhook | n/a | n/a | n/a | ================================================ FILE: docs/registry/dynamodb.md ================================================ # The DynamoDB registry As opposed to the default TXT registry, the DynamoDB registry stores DNS record metadata in an AWS DynamoDB table instead of in TXT records in a hosted zone. This following tutorial extends [Setting up ExternalDNS for Services on AWS](../tutorials/aws.md) to use the DynamoDB registry instead. ## IAM permissions The ExternalDNS [IAM Policy](../tutorials/aws.md#iam-policy) must additionally be granted the following permissions: ```json { "Effect": "Allow", "Action": [ "DynamoDB:DescribeTable", "DynamoDB:PartiQLDelete", "DynamoDB:PartiQLInsert", "DynamoDB:PartiQLUpdate", "DynamoDB:Scan" ], "Resource": [ "arn:aws:dynamodb:*:*:table/external-dns" ] } ``` The region and account ID may be specified explicitly specified instead of using wildcards. ## Create a DynamoDB Table By default, the DynamoDB registry stores data in the table named `external-dns` and it needs to exist before configuring ExternalDNS to use the DynamoDB registry. If the DynamoDB table has a different name, it may be specified using the `--dynamodb-table` flag. If the DynamoDB table is in a different region, it may be specified using the `--dynamodb-region` flag. The following command creates a DynamoDB table with the name: `external-dns`: > The table must have a partition (HASH) key named `k` of type string (`S`) and the table must NOT have a sort (RANGE) key. ```bash aws dynamodb create-table \ --table-name external-dns \ --attribute-definitions \ AttributeName=k,AttributeType=S \ --key-schema \ AttributeName=k,KeyType=HASH \ --provisioned-throughput \ ReadCapacityUnits=5,WriteCapacityUnits=5 \ --table-class STANDARD ``` ## Set up a hosted zone Follow [Set up a hosted zone](../tutorials/aws.md#set-up-a-hosted-zone) ## Modify ExternalDNS deployment The ExternalDNS deployment from [Deploy ExternalDNS](../tutorials/aws.md#deploy-externaldns) needs the following modifications: * `--registry=txt` should be changed to `--registry=dynamodb` * Add `--dynamodb-table=external-dns` to specify the name of the DynamoDB table, its value defaults to `external-dns` * Add `--dynamodb-region=us-east-1` to specify the region of the DynamoDB table For example: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns labels: app.kubernetes.io/name: external-dns spec: strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: external-dns template: metadata: labels: app.kubernetes.io/name: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=dynamodb # previously, --registry=txt - --dynamodb-table=external-dns # defaults to external-dns - --dynamodb-region=us-east-1 # set to the region the DynamoDB table in - --txt-owner-id=my-hostedzone-identifier env: - name: AWS_DEFAULT_REGION value: us-east-1 # change to region where EKS is installed # # Uncomment below if using static credentials # - name: AWS_SHARED_CREDENTIALS_FILE # value: /.aws/credentials # volumeMounts: # - name: aws-credentials # mountPath: /.aws # readOnly: true # volumes: # - name: aws-credentials # secret: # secretName: external-dns ``` ## Validate ExternalDNS works Create either a [Service](../tutorials/aws.md#verify-externaldns-works-service-example) or an [Ingress](../tutorials/aws.md#verify-externaldns-works-ingress-example) and After roughly two minutes, check that the corresponding entry was created in the DynamoDB table: ```bash aws dynamodb scan --table-name external-dns ``` This will show something like: ```json { "Items": [ { "k": { "S": "nginx.example.com#A#" }, "o": { "S": "my-identifier" }, "l": { "M": { "resource": { "S": "service/default/nginx" } } } } ], "Count": 1, "ScannedCount": 1, "ConsumedCapacity": null } ``` ## Clean up In addition to the clean up steps in [Setting up ExternalDNS for Services on AWS](../tutorials/aws.md#clean-up), delete the DynamoDB table that was used as a registry. ```bash aws dynamodb delete-table \ --table-name external-dns ``` ## Caching The DynamoDB registry can optionally cache DNS records read from the provider. This can mitigate rate limits imposed by the provider. Caching is enabled by specifying a cache duration with the `--txt-cache-interval` flag. ## Migration from TXT registry If any ownership TXT records exist for the configured owner, the DynamoDB registry will migrate the metadata therein to the DynamoDB table. If any such TXT records exist, any previous values for `--txt-prefix`, `--txt-suffix`, `--txt-wildcard-replacement`, and `--txt-encrypt-aes-key` must be supplied. If TXT records are in the set of managed record types specified by `--managed-record-types`, it will then delete the ownership TXT records on a subsequent reconciliation. ================================================ FILE: docs/registry/registry.md ================================================ # Registries A registry persists metadata pertaining to DNS records. The most important metadata is the owning external-dns deployment. This is specified using the `--txt-owner-id` flag, specifying a value unique to the deployment of external-dns and which doesn't change for the lifetime of the deployment. Deployments in different clusters but sharing a DNS zone need to use different owner IDs. The registry implementation is specified using the `--registry` flag. ## Supported registries * [txt](txt.md) (default) - Stores metadata in TXT records in the same provider. * [dynamodb](dynamodb.md) - Stores metadata in an AWS DynamoDB table. * noop - Passes metadata directly to the provider. For most providers, this means the metadata is not persisted. * aws-sd - Stores metadata in AWS Service Discovery. Only usable with the `aws-sd` provider. ================================================ FILE: docs/registry/txt.md ================================================ # The TXT registry The TXT registry is the default registry. It stores DNS record metadata in TXT records, using the same provider. > Note: > > - If you plan to manage apex domains with external-dns whilst using a txt registry, you should ensure when using `--txt-prefix` that you specify the record type substitution and that it ends in a period (**.**). > The record should be created under the same domain as the apex record being managed, i.e. `--txt-prefix=someprefix-%{record_type}.` > - `--txt-prefix` and `--txt-suffix` contribute to the 63-byte maximum record length. To avoid errors, use them only if absolutely required and keep them as short as possible. ## Record Format Options ### For version `v0.18+` The TXT registry supports single format for storing DNS record metadata: - Creates a TXT record with record type information (e.g., 'a-' prefix for A records) The TXT registry would try to guarantee a consistency in between providers and sources, if provider supports the behaviour. If configured `--txt-prefix="%{record_type}-abc-."` for apex domain `ex.com` the expected result is | Name | TYPE | | :------------------: | :-----: | | `cname-abc-.ex.com.` | `TXT` | | `ex.com.` | `CNAME` | For the domain `www.ex.com` the expected result is | Name | TYPE | | :----------------------: | :-----: | | `cname-abc-.www.ex.com.` | `TXT` | | `www.ex.com.` | `CNAME` | If configured `--txt-suffix="-.%{record_type}"` for apex domain `ex.com`, the expected result would be `ex-.a.com`, which fails to create a TXT record because it does not exist within the managed zone. For the domain `www.ex.com` the expected result is | Name | TYPE | | :------------------: | :-----: | | `www-.cname.ex.com.` | `TXT` | | `www.ex.com.` | `CNAME` | ### Manually Cleanup Legacy TXT Records > While deleting registry TXT records won't cause downtime, a well-thought-out migration and cleanup plan is crucial. Occasionally, it may be necessary to remove outdated TXT records from your registry. An example script for AWS can be found in [scripts/aws-cleanup-legacy-txt-records.py](../../scripts/aws-cleanup-legacy-txt-records.py) with instructions on how to run it. The script performs targeted deletion of TXT records that include `ResourceRecords` matching the `heritage=external-dns,external-dns/owner=default` or similar pattern. In the event of unintended deletion of all TXT records managed by `external-dns`, `external-dns` will initiate a full DNS record regeneration, along with`TXT` and `non-TXT` records. Just be aware, this operation's duration is directly proportional to the DNS estate size." ### For version `v0.16.0 & v0.16.1` The TXT registry supports two formats for storing DNS record metadata: - Legacy format: Creates a TXT record without record type information - New format: Creates a TXT record with record type information (e.g., 'a-' prefix for A records) By default, the TXT registry creates records in both formats for backwards compatibility. You can configure it to use only the new format by using the `--txt-new-format-only` flag. This reduces the number of TXT records created, which can be helpful when working with provider-specific record limits. Note: The following record types always use only the new format regardless of this setting: - AAAA records - Encrypted TXT records (when using `--txt-encrypt-enabled`) Example: ```sh # Default behavior - creates both formats external-dns --provider=aws --source=ingress --managed-record-types=A --managed-record-types=TXT # Only create new format records (alongside other required flags) external-dns --provider=aws --source=ingress --managed-record-types=A --managed-record-types=TXT --txt-new-format-only ``` The `--txt-new-format-only` flag should be used in addition to your existing external-dns configuration flags. It does not implicitly configure TXT record handling - you still need to specify `--managed-record-types=TXT` if you want external-dns to manage TXT records. ### Migration to New Format Only > Note: `external-dns` will not automatically remove legacy format records when switching to new-format-only mode. You'll need to clean up the old records manually if desired. When transitioning from dual-format to new-format-only records: - Ensure all your `external-dns` instances support the new format - Enable the `--txt-new-format-only` flag on your external-dns instances Manually clean up any existing legacy format TXT records from your DNS provider ## Prefixes and Suffixes In order to avoid having the registry TXT records collide with TXT or CNAME records created from sources, you can configure a fixed prefix or suffix to be added to the first component of the domain of all registry TXT records. The prefix or suffix may not be changed after initial deployment, lest the registry records be orphaned and the metadata be lost. The prefix or suffix may contain the substring `%{record_type}`, which is replaced with the record type of the DNS record for which it is storing metadata. The prefix is specified using the `--txt-prefix` flag and the suffix is specified using the `--txt-suffix` flag. The two flags are mutually exclusive. ## Wildcard Replacement The `--txt-wildcard-replacement` flag specifies a string to use to replace the "\*" in registry TXT records for wildcard domains. Without using this, registry TXT records for wildcard domains will have invalid domain syntax and be rejected by most providers. ## Encryption Registry TXT records may contain information, such as the internal ingress name or namespace, considered sensitive, , which attackers could exploit to gather information about your infrastructure. By encrypting TXT records, you can protect this information from unauthorized access. Encryption is enabled by setting the `--txt-encrypt-enabled`. The 32-byte AES-256-GCM encryption key must be specified in URL-safe base64 form (recommended) or be a plain text, using the `--txt-encrypt-aes-key=` flag. Note that the key used for encryption should be a secure key and properly managed to ensure the security of your TXT records. ### Generating the TXT Encryption Key Python ```python python -c 'import os,base64; print(base64.standard_b64encode(os.urandom(32)).decode())' ``` Bash ```shell dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64; echo ``` OpenSSL ```shell openssl rand -base64 32 ``` PowerShell ```powershell # Add System.Web assembly to session, just in case Add-Type -AssemblyName System.Web [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.Web.Security.Membership]::GeneratePassword(32,4))) ``` Terraform ```hcl resource "random_password" "txt_key" { length = 32 } ``` ### Manually Encrypting/Decrypting TXT Records In some cases you might need to edit registry TXT records. The following example Go code encrypts and decrypts such records. ```go package main import ( b64 "encoding/base64" "fmt" "sigs.k8s.io/external-dns/endpoint" ) func main() { keys := []string{ "ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=", // safe base64 url encoded 44 bytes and 32 when decoded "01234567890123456789012345678901", // plain txt 32 bytes "passphrasewhichneedstobe32bytes!", // plain txt 32 bytes } for _, k := range keys { key := []byte(k) if len(key) != 32 { // if key is not a plain txt let's decode var err error if key, err = b64.StdEncoding.DecodeString(string(key)); err != nil || len(key) != 32 { fmt.Errorf("the AES Encryption key must have a length of 32 byte") } } encrypted, _ := endpoint.EncryptText( "heritage=external-dns,external-dns/owner=example,external-dns/resource=ingress/default/example", key, nil, ) decrypted, _, err := endpoint.DecryptText(encrypted, key) if err != nil { fmt.Println("Error decrypting:", err, "for key:", k) } fmt.Println(decrypted) } } ``` ## Caching The TXT registry can optionally cache DNS records read from the provider. This can mitigate rate limits imposed by the provider. Caching is enabled by specifying a cache duration with the `--txt-cache-interval` flag. ## OwnerID migration > Automating DNS migrations with third-party tools can be risky. DNS is often business-critical, and without deep understanding of the environment, 3rd party automation tools can do more harm than good. The owner ID of the TXT records managed by external-dns instance can be updated. When `--migrate-from-txt-owner` is set, it will enable the migration checks in the run loop using `--txt-owner-id=new-owner-id` and the value you defined for this flag. If you want to test the outputs of a migration beforehand, you can use the `--dry-run` flag along with `--migrate-from-txt-owner`. Example, if you had a standard deployment like so: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: replicas: 1 selector: matchLabels: app: external-dns strategy: type: Recreate template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 imagePullPolicy: Always args: - "--txt-prefix=%{record_type}-" - "--txt-cache-interval=2m" - "--log-level=debug" - "--log-format=text" - "--txt-owner-id=old-owner" - "--policy=sync" - "--provider=some-provider" - "--registry=txt" - "--interval=1m" - "--source=ingress" ``` You can update your deployment to migrate like so : ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: replicas: 1 selector: matchLabels: app: external-dns strategy: type: Recreate template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns imagePullPolicy: Always image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - "--txt-prefix=%{record_type}-" - "--txt-cache-interval=2m" - "--log-level=debug" - "--log-format=text" - "--txt-owner-id=new-owner" - "--migrate-from-txt-owner=old-owner" - "--policy=sync" - "--provider=some-provider" - "--registry=txt" - "--interval=1m" - "--source=ingress" ``` If you didn't set the owner ID, the value set by external-dns is `default`. You can set the `--migrate-from-txt-owner` flag to `default` to migrate the associated records. ### OwnerID migration: multi-cluster considerations > Warning: The `--migrate-from-txt-owner` flag combined with `policy=sync` can be unsafe in shared hosted zones when multiple clusters previously used the same TXT owner value (for example `default`). In a shared hosted zone, if one cluster runs ExternalDNS with `policy=sync` and `--migrate-from-txt-owner=default`, it may attempt to delete DNS records that belong to other clusters which still use `owner=default`. To avoid this, do not share the same TXT owner value across clusters in any zone where `policy=sync` or migration flags will be used. #### Per-cluster owner IDs For multi-cluster setups sharing a hosted zone: - Assign a **unique** `--txt-owner-id` to each cluster (for example `cluster1`, `cluster2`) and document this convention clearly in your platform configuration. - Avoid using a common owner such as `default` across clusters in a shared zone if any cluster will run with `policy=sync` or use `--migrate-from-txt-owner`. #### Example migration sequence for shared zones When migrating from a shared owner (such as `default`) in a shared hosted zone: 1. While still using `policy=upsert-only` (or equivalent), roll out cluster-specific `--txt-owner-id` values and ensure *new* records are created with the cluster’s own owner ID. 2. Avoid `--migrate-from-txt-owner=` unless you can guarantee that only a single cluster has records with `` in that hosted zone, or perform the migration in an isolated zone where only that cluster writes records. ### When to avoid owner migration The following pattern is **not recommended** and may cause record deletion for other clusters: - Multiple clusters share a Route53 hosted zone and all existing records use `owner=default`. - Only one cluster is upgraded to use `policy=sync`, `--txt-owner-id=`, and `--migrate-from-txt-owner=default`, while other clusters still use `owner=default`. In this situation, the upgraded cluster can treat other clusters’ records as orphans and schedule them for deletion during synchronization. Prefer per-cluster zones, manual TXT record adjustment, or fully coordinated migration of all clusters if the migration flag must be used. ================================================ FILE: docs/release.md ================================================ # Release ## Release cycle Currently we don't release regularly. Whenever we think it makes sense to release a new version we do it. You might want to ask in our Slack channel [external-dns](https://kubernetes.slack.com/archives/C771MKDKQ) when the next release will come out. ## Staging Release cycle A new staging image is released weekly and can be found at [gcr.io/k8s-staging-external-dns/external-dns](https://console.cloud.google.com/gcr/images/k8s-staging-external-dns/GLOBAL/external-dns?pli=1&inv=1&invt=AboL6Q). > There is a time lag between merging changes into the master branch and the subsequent creation of the staging image. Example command to fetch `10` most recent staging images: ```sh export EXT_DNS_VERSION="v0.20.0" curl -sLk https://gcr.io/v2/k8s-staging-external-dns/external-dns/tags/list | jq | grep "$EXT_DNS_VERSION" | tail -n 10 ``` ## Versioning convention These are the conventions that we will be using for releases following `0.7.6`: - **Patch** version should be updated if we need to merge bugfixes, e.g. provider a does need a fix in order make updates working again. I would see updating or improving documentation here. - **Minor** version should be updated if new features are implemented in existing providers or new provider get introduced. - **Major** version should be upgraded if we introduce breaking changes. ### Semantic Versioning Discipline External-DNS follows semantic versioning principles: - `0.x` → pre-stable, APIs subject to change. - `1.x` → not yet considered. > **Versioning & Releases** > External-DNS opts to stay within `0.x` versioning scheme. > We strive for stability, but reserve the right to introduce breaking changes in minor version bumps when necessary. ## How to release a new image ### Prerequisite We use https://github.com/cli/cli to automate the release process. Please install it according to the [official documentation](https://github.com/cli/cli#installation). You must be an official maintainer of the project to be able to do a release. ### Steps - Run `scripts/releaser.sh` to create a new GitHub release. Alternatively you can create a release in the GitHub UI making sure to click on the autogenerate release node feature. - The step above will trigger the Kubernetes based CI/CD system [Prow](https://prow.k8s.io/?repo=kubernetes-sigs%2Fexternal-dns). Verify that a new image was built and uploaded to `gcr.io/k8s-staging-external-dns/external-dns`. - Create a PR in the [k8s.io repo](https://github.com/kubernetes/k8s.io) by taking the current staging image using the sha256 digest. They can be obtained with `scripts/get-sha256.sh`. Once the PR is merged, the image will be live with the corresponding tag specified in the PR. - See https://github.com/kubernetes/k8s.io/pull/8466 for reference - Verify that the image is pullable with the given tag - `docker run registry.k8s.io/external-dns/external-dns:v0.x.0 --version` - Branch out from the default branch and run `scripts/version-updater.sh` to update the image tag used in the kustomization.yaml and in documentation. - Create the PR with this version change. - Create an issue to release the corresponding Helm chart via the chart release process (below) assigned to a chart maintainer - Once the PR is merged, all is done :-) ## How to release a new chart version The chart needs to be released in response to an ExternalDNS image release or on an as-needed basis; this should be triggered by an issue to release the chart. ### Steps - Create a PR to update _Chart.yaml_ with the ExternalDNS version in `appVersion`, agreed on chart release version in `version` and `annotations` showing the changes - Validate that the chart linting is successful - Merge the PR to trigger a GitHub action to release the chart ================================================ FILE: docs/scripts/index.html.gotmpl ================================================ ================================================ FILE: docs/scripts/requirements.txt ================================================ mkdocs-git-revision-date-localized-plugin == 1.5.1 mkdocs == 1.6.1 mkdocs-macros-plugin == 1.5.0 mkdocs-material == 9.7.5 mkdocs-literate-nav == 0.6.2 mkdocs-same-dir == 0.1.3 mike == 2.1.4 ================================================ FILE: docs/snippets/contributing/collect-extdns-info.sh ================================================ #!/usr/bin/env bash # Collect external-dns version, startup args, and logs. # # Usage: # [NAMESPACE=external-dns] [SINCE=5m] ./collect-extdns-info.sh # # Examples: # ./collect-extdns-info.sh # NAMESPACE=my-ns ./collect-extdns-info.sh # SINCE=30m ./collect-extdns-info.sh # NAMESPACE=my-ns SINCE=1h ./collect-extdns-info.sh set -euo pipefail NS="${NAMESPACE:-external-dns}" SINCE="${SINCE:-5m}" OUT="extdns-info-$(date +%Y%m%d-%H%M%S).txt" { echo "=== external-dns version ===" out=$(kubectl get pod -n "${NS}" \ -l app.kubernetes.io/name=external-dns \ -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' \ 2>/dev/null) echo "${out:-(not found)}" echo "" echo "=== external-dns startup args ===" out=$(kubectl get pod -n "${NS}" \ -l app.kubernetes.io/name=external-dns \ -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{range .spec.containers[*]}{range .args[*]}{@}{"\n"}{end}{end}{end}' \ 2>/dev/null) echo "${out:-(not found)}" echo "" echo "=== external-dns logs (last ${SINCE}) ===" out=$(kubectl logs -n "${NS}" \ -l app.kubernetes.io/name=external-dns \ --since="${SINCE}" --prefix=true 2>/dev/null) echo "${out:-(not found)}" } | tee "${OUT}" echo "" echo "Saved to: ${OUT}" echo "Review for sensitive data before sharing." ================================================ FILE: docs/snippets/contributing/collect-resources.sh ================================================ #!/usr/bin/env bash # Collect Kubernetes resources relevant to your external-dns source type. # # Usage: # RESOURCE= ./collect-resources.sh # # Examples: # RESOURCE=ingress ./collect-resources.sh # RESOURCE="ingress,service" ./collect-resources.sh # RESOURCE="gateway,httproute" ./collect-resources.sh # RESOURCE=dnsendpoint ./collect-resources.sh set -euo pipefail RESOURCE="${RESOURCE:?Usage: RESOURCE= $0}" OUT="extdns-resources-$(date +%Y%m%d-%H%M%S).txt" { echo "=== ${RESOURCE} (all namespaces) ===" kubectl get "${RESOURCE}" -A -o yaml 2>/dev/null || echo "(not found)" } | tee "${OUT}" echo "" echo "Saved to: ${OUT}" echo "Review for sensitive data before sharing." ================================================ FILE: docs/snippets/exoscale/extdns.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: # Only use if you're also using RBAC # serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.17.0 args: - --source=ingress # or service or both - --provider=exoscale - --domain-filter={{ my-domain }} - --policy=sync # if you want DNS entries to get deleted as well - --txt-owner-id={{ owner-id-for-this-external-dns }} - --exoscale-apikey={{ api-key}} - --exoscale-apisecret={{ api-secret }} # - --exoscale-apizone={{ api-zone }} # - --exoscale-apienv={{ api-env }} ================================================ FILE: docs/snippets/exoscale/how-to-test.yaml ================================================ --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/target: {{ Elastic-IP-address }} spec: ingressClassName: nginx rules: - host: via-ingress.example.com http: paths: - backend: service: name: "nginx" port: number: 80 path: / pathType: Prefix --- apiVersion: v1 kind: Service metadata: name: nginx spec: ports: - port: 80 targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 ================================================ FILE: docs/snippets/exoscale/rbac.yaml ================================================ --- apiVersion: v1 kind: ServiceAccount metadata: name: external-dns namespace: default --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default ================================================ FILE: docs/snippets/security-context/extdns-limited-privilege.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.17.0 args: - ... # your arguments here securityContext: runAsNonRoot: true runAsUser: 65534 readOnlyRootFilesystem: true capabilities: drop: ["ALL"] ================================================ FILE: docs/snippets/traefik-proxy/ingress-route-default.yaml ================================================ apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: traefik-ingress annotations: external-dns.alpha.kubernetes.io/target: traefik.example.com kubernetes.io/ingress.class: traefik spec: entryPoints: - web - websecure routes: - match: Host(`application.example.com`) kind: Rule services: - name: service namespace: namespace port: port ================================================ FILE: docs/snippets/traefik-proxy/ingress-route-public-private.yaml ================================================ --- apiVersion: traefik.io/v1 kind: IngressRoute metadata: name: traefik-public-abc annotations: kubernetes.io/ingress.class: traefik-public spec: entryPoints: - web - websecure routes: - match: Host(`application.public.example.com`) kind: Rule services: - name: service namespace: namespace port: port tls: secretName: traefik-tls-cert-public --- apiVersion: traefik.io/v1 kind: IngressRoute metadata: name: traefik-private-abc annotations: kubernetes.io/ingress.class: traefik-private spec: entryPoints: - web - websecure routes: - match: Host(`application.private.tlc`) kind: Rule services: - name: service namespace: namespace port: port tls: secretName: traefik-tls-cert-private ================================================ FILE: docs/snippets/traefik-proxy/traefik-public-private-config.yaml ================================================ --- type: public providers: kubernetesCRD: ingressClass: traefik-public kubernetesIngress: ingressClass: traefik-public --- type: private providers: kubernetesCRD: ingressClass: traefik-private kubernetesIngress: ingressClass: traefik-private ================================================ FILE: docs/snippets/traefik-proxy/with-cluster-rbac.yaml ================================================ --- apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list","watch"] - apiGroups: ["traefik.containo.us","traefik.io"] resources: ["ingressroutes", "ingressroutetcps", "ingressrouteudps"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns # update this to the desired external-dns version image: registry.k8s.io/external-dns/external-dns:v0.17.0 args: - --source=traefik-proxy - --provider=aws - --registry=txt - --txt-owner-id=my-identifier ================================================ FILE: docs/snippets/traefik-proxy/without-rbac.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns # update this to the desired external-dns version image: registry.k8s.io/external-dns/external-dns:v0.17.0 args: - --source=traefik-proxy - --provider=aws - --registry=txt - --txt-owner-id=my-identifier ================================================ FILE: docs/snippets/tutorials/coredns/coredns-groups.yaml ================================================ --- apiVersion: v1 kind: Service metadata: name: a annotations: external-dns.alpha.kubernetes.io/hostname: a.domain.local external-dns.alpha.kubernetes.io/coredns-group: "g1" spec: type: LoadBalancer status: loadBalancer: ingress: - ip: 127.0.0.1 --- apiVersion: v1 kind: Service metadata: name: b annotations: external-dns.alpha.kubernetes.io/hostname: b.domain.local external-dns.alpha.kubernetes.io/coredns-group: "g1" spec: type: LoadBalancer status: loadBalancer: ingress: - ip: 127.0.0.2 --- apiVersion: v1 kind: Service metadata: name: c annotations: external-dns.alpha.kubernetes.io/hostname: c.subdom.domain.local external-dns.alpha.kubernetes.io/coredns-group: "g2" spec: type: LoadBalancer status: loadBalancer: ingress: - ip: 127.0.0.3 --- apiVersion: v1 kind: Service metadata: name: d annotations: external-dns.alpha.kubernetes.io/hostname: d.subdom.domain.local external-dns.alpha.kubernetes.io/coredns-group: "g2" spec: type: LoadBalancer status: loadBalancer: ingress: - ip: 127.0.0.4 ================================================ FILE: docs/snippets/tutorials/coredns/etcd.yaml ================================================ # kubectl apply -f docs/snippets/tutorials/coredns/etcd.yaml # kubectl delete -f docs/snippets/tutorials/coredns/etcd.yaml --- apiVersion: v1 kind: Service metadata: name: etcd namespace: default spec: type: ClusterIP clusterIP: None ports: - name: etcd-client port: 2379 - name: etcd-server port: 2380 - name: etcd-metrics port: 8080 selector: app: etcd --- apiVersion: v1 kind: Service metadata: name: etcd-nodeport-external namespace: default spec: type: NodePort ports: - port: 2379 targetPort: 2379 nodePort: 32379 # must match kind config port mapping selector: app: etcd --- apiVersion: apps/v1 kind: StatefulSet metadata: name: etcd namespace: default spec: serviceName: etcd replicas: 1 selector: matchLabels: app: etcd template: metadata: labels: app: etcd annotations: serviceName: etcd spec: containers: - name: etcd image: quay.io/coreos/etcd:v3.5.15 command: - /usr/local/bin/etcd - --name=$(HOSTNAME) - --listen-peer-urls=$(URI_SCHEME)://0.0.0.0:2380 - --listen-client-urls=$(URI_SCHEME)://0.0.0.0:2379 - --advertise-client-urls=$(URI_SCHEME)://$(HOSTNAME).$(SERVICE_NAME):2379 - --data-dir=/var/lib/etcd ports: - containerPort: 2379 volumeMounts: - name: data mountPath: /var/lib/etcd env: - name: K8S_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace - name: HOSTNAME valueFrom: fieldRef: fieldPath: metadata.name - name: SERVICE_NAME valueFrom: fieldRef: fieldPath: metadata.annotations['serviceName'] - name: ETCDCTL_ENDPOINTS value: $(HOSTNAME).$(SERVICE_NAME):2379 - name: URI_SCHEME value: "http" volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 50Mi ================================================ FILE: docs/snippets/tutorials/coredns/fixtures.yaml ================================================ # kubectl apply -f docs/snippets/tutorials/coredns/fixtures.yaml # kubectl delete -f docs/snippets/tutorials/coredns/fixtures.yaml # kubectl get svc -l svc=test-svc --- apiVersion: v1 kind: Service metadata: name: a-g1-record labels: svc: test-svc annotations: external-dns.alpha.kubernetes.io/hostname: a.example.org external-dns.alpha.kubernetes.io/coredns-group: "g1" cluster-name: "cluster1" namespace: default spec: type: LoadBalancer ports: - port: 80 name: http targetPort: 80 selector: app: test-app --- apiVersion: v1 kind: Service metadata: name: aa-g1-record labels: svc: test-svc annotations: external-dns.alpha.kubernetes.io/hostname: aa.example.org external-dns.alpha.kubernetes.io/coredns-group: "g1" cluster-name: "cluster1" namespace: default spec: type: LoadBalancer ports: - port: 80 name: http targetPort: 80 selector: app: test-app ================================================ FILE: docs/snippets/tutorials/coredns/kind.yaml ================================================ # ref: https://kind.sigs.k8s.io/docs/user/quick-start/ # https://kind.sigs.k8s.io/docs/user/configuration/#extra-port-mappings # kind create cluster --config=docs/snippets/tutorials/coredns/kind.yaml # kind delete cluster --name coredns-etcd # kubectl cluster-info --context kind-coredns-etcd # kubectl get nodes -o wide --- kind: Cluster apiVersion: kind.x-k8s.io/v1alpha4 name: coredns-etcd networking: apiServerAddress: 127.0.0.1 apiServerPort: 6443 nodes: - role: control-plane image: kindest/node:v1.33.0 kubeadmConfigPatches: - | kind: InitConfiguration nodeRegistration: kubeletExtraArgs: node-labels: "ingress-ready=true" extraPortMappings: - containerPort: 80 hostPort: 8080 listenAddress: "0.0.0.0" protocol: TCP - containerPort: 43 hostPort: 4443 listenAddress: "0.0.0.0" protocol: TCP - containerPort: 32379 # inside kind node hostPort: 32379 # exposed on host listenAddress: "0.0.0.0" protocol: TCP - role: worker image: kindest/node:v1.33.0 ================================================ FILE: docs/snippets/tutorials/coredns/values-coredns.yaml ================================================ # kubectl logs deploy/coredns -n default -c coredns # ref: https://github.com/coredns/helm/blob/master/charts/coredns/values.yaml isClusterService: false service: name: coredns port: 53 annotations: {} clusterIP: "" # Main customization servers: - zones: - zone: . port: 53 plugins: - name: errors - name: debug # <── enables debug mode - name: health configBlock: |- lameduck 5s - name: ready # to query kubernetes API for data - name: kubernetes parameters: cluster.local 10.0.0.0/24 configBlock: |- pods insecure fallthrough in-addr.arpa ip6.arpa ttl 30 - name: etcd parameters: "example.org" configBlock: | stubzones path /skydns endpoint http://etcd.default.svc.cluster.local:2379 fallthrough - name: log # <── log each DNS query - name: forward parameters: ". /etc/resolv.conf" - name: cache parameters: 30 - name: reload - name: loop - name: loadbalance replicaCount: 1 # required to debug DNS resolution from within CoreDNS pods # kubectl logs deploy/coredns -n default -c resolv-check --tail=50 initContainers: - name: resolv-check image: busybox:1.37 command: ["sh", "-c", "echo '--- /etc/resolv.conf ---'; cat /etc/resolv.conf; echo '---------------------------'; nslookup kubernetes.default.svc.cluster.local || true; sleep 5"] ================================================ FILE: docs/snippets/tutorials/coredns/values-extdns-coredns.yaml ================================================ # ref: https://github.com/kubernetes-sigs/external-dns/blob/master/charts/external-dns/values.yaml provider: name: coredns env: - name: ETCD_URLS value: "http://etcd.default.svc.cluster.local:2379" txtOwnerId: cluster1 # Filter resources queried for endpoints by annotation, using label selector semantics annotationFilter: cluster-name=cluster1 domainFilters: - example.org # Sources define what ExternalDNS will use to discover endpoints sources: - service # Policy options policy: sync logLevel: debug interval: 1m # RBAC configuration rbac: create: true # Optional: tune resource requests resources: requests: cpu: 100m memory: 64Mi limits: cpu: 200m memory: 128Mi ================================================ FILE: docs/sources/about.md ================================================ # About A source in ExternalDNS defines where DNS records are discovered from within your infrastructure. Each source corresponds to a specific Kubernetes resource or external system that declares DNS names. ExternalDNS watches the specified sources for hostname information and uses it to create, update, or delete DNS records accordingly. Multiple sources can be configured simultaneously to support diverse environments. | Source | Resources | annotation-filter | label-filter | |-----------------------------------------|-------------------------------------------------------------------------------|:-----------------:|:------------:| | ambassador-host | Host.getambassador.io | Yes | Yes | | connector | | | | | contour-httpproxy | HttpProxy.projectcontour.io | Yes | | | [crd](crd.md) | DNSEndpoint.externaldns.k8s.io | Yes | Yes | | [f5-virtualserver](f5-virtualserver.md) | VirtualServer.cis.f5.com | Yes | | | [gateway-grpcroute](gateway.md) | GRPCRoute.gateway.networking.k8s.io | Yes | Yes | | [gateway-httproute](gateway.md) | HTTPRoute.gateway.networking.k8s.io | Yes | Yes | | [gateway-tcproute](gateway.md) | TCPRoute.gateway.networking.k8s.io | Yes | Yes | | [gateway-tlsroute](gateway.md) | TLSRoute.gateway.networking.k8s.io | Yes | Yes | | [gateway-udproute](gateway.md) | UDPRoute.gateway.networking.k8s.io | Yes | Yes | | [gloo-proxy](gloo-proxy.md) | Proxy.gloo.solo.io | | | | [ingress](ingress.md) | Ingress.networking.k8s.io | Yes | Yes | | [istio-gateway](istio.md) | Gateway.networking.istio.io | Yes | | | [istio-virtualservice](istio.md) | VirtualService.networking.istio.io | Yes | | | [kong-tcpingress](kong.md) | TCPIngress.configuration.konghq.com | Yes | | | [node](nodes.md) | Node | Yes | Yes | | [openshift-route](openshift.md) | Route.route.openshift.io | Yes | Yes | | [pod](pod.md) | Pod | Yes | Yes | | [service](service.md) | Service | Yes | Yes | | skipper-routegroup | RouteGroup.zalando.org | Yes | | | [traefik-proxy](traefik-proxy.md) | IngressRoute.traefik.io IngressRouteTCP.traefik.io IngressRouteUDP.traefik.io | Yes | | ================================================ FILE: docs/sources/crd/dnsendpoint-aws-example.yaml ================================================ apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: examplednsrecord spec: endpoints: - dnsName: subdomain.foo.bar.com providerSpecific: - name: "aws/failover" value: "PRIMARY" - name: "aws/health-check-id" value: "asdf1234-as12-as12-as12-asdf12345678" - name: "aws/evaluate-target-health" value: "true" recordType: CNAME setIdentifier: some-unique-id targets: - other-subdomain.foo.bar.com ================================================ FILE: docs/sources/crd/dnsendpoint-example.yaml ================================================ apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: examplednsrecord spec: endpoints: - dnsName: foo.bar.com recordTTL: 180 recordType: A targets: - 192.168.99.216 # Provider specific configurations are set like an annotation would on other sources providerSpecific: - name: external-dns.alpha.kubernetes.io/cloudflare-proxied value: "true" ================================================ FILE: docs/sources/crd.md ================================================ # CRD Source CRD source provides a generic mechanism to manage DNS records in your favorite DNS provider supported by external-dns. ## Details CRD source watches for a user specified CRD to extract [Endpoints](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/endpoint/endpoint.go) from its `Spec`. So users need to create such a CRD and register it to the kubernetes cluster and then create new object(s) of the CRD specifying the Endpoints. ## Registering CRD Here is typical example of [CRD API type](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/endpoint/endpoint.go) which provides Endpoints to `CRD source`: ```go type TTL int64 type Targets []string type ProviderSpecificProperty struct { Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` } type ProviderSpecific []ProviderSpecificProperty type Labels map[string]string type Endpoint struct { // The hostname of the DNS record DNSName string `json:"dnsName,omitempty"` // The targets the DNS record points to Targets Targets `json:"targets,omitempty"` // RecordType type of record, e.g. CNAME, A, SRV, TXT etc RecordType string `json:"recordType,omitempty"` // TTL for the record RecordTTL TTL `json:"recordTTL,omitempty"` // Labels stores labels defined for the Endpoint // +optional Labels Labels `json:"labels,omitempty"` // ProviderSpecific stores provider specific config // +optional ProviderSpecific ProviderSpecific `json:"providerSpecific,omitempty"` } type DNSEndpointSpec struct { Endpoints []*Endpoint `json:"endpoints,omitempty"` } type DNSEndpointStatus 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 // DNSEndpoint is the CRD wrapper for Endpoint // +k8s:openapi-gen=true // +kubebuilder:resource:path=dnsendpoints // +kubebuilder:subresource:status type DNSEndpoint struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec DNSEndpointSpec `json:"spec,omitempty"` Status DNSEndpointStatus `json:"status,omitempty"` } ``` Refer to [kubebuilder](https://github.com/kubernetes-sigs/kubebuilder) to create and register the CRD. ## Usage One can use CRD source by specifying `--source` flag with `crd` and specifying the ApiVersion and Kind of the CRD with `--crd-source-apiversion` and `crd-source-kind` respectively. for e.g: ```sh build/external-dns --source crd --crd-source-apiversion externaldns.k8s.io/v1alpha1 --crd-source-kind DNSEndpoint --provider inmemory --once --dry-run ``` ## Creating DNS Records Create the objects of CRD type by filling in the fields of CRD and DNS record would be created accordingly. ### Example Here is an example [CRD manifest](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/charts/external-dns/crds/dnsendpoints.externaldns.k8s.io.yaml) generated by kubebuilder. Apply this to register the CRD ```sh $ kubectl apply --server-side=true -f "https://raw.githubusercontent.com/kubernetes-sigs/external-dns/master/config/crd/standard/dnsendpoints.externaldns.k8s.io.yaml" customresourcedefinition.apiextensions.k8s.io "dnsendpoints.externaldns.k8s.io" created ``` Then you can create the dns-endpoint yaml similar to [dnsendpoint-example](crd/dnsendpoint-example.yaml) ```sh $ kubectl apply -f docs/sources/crd/dnsendpoint-example.yaml dnsendpoint.externaldns.k8s.io "examplednsrecord" created ``` Run external-dns in dry-mode to see whether external-dns picks up the DNS record from CRD. ```sh $ build/external-dns --source crd --crd-source-apiversion externaldns.k8s.io/v1alpha1 --crd-source-kind DNSEndpoint --provider inmemory --once --dry-run INFO[0000] running in dry-run mode. No changes to DNS records will be made. INFO[0000] Connected to cluster at https://192.168.99.100:8443 INFO[0000] CREATE: foo.bar.com 180 IN A 192.168.99.216 INFO[0000] CREATE: foo.bar.com 0 IN TXT "heritage=external-dns,external-dns/owner=default" ``` ### Using CRD source to manage DNS records in different DNS providers [CRD source](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/sources/crd.md) provides a generic mechanism and declarative way to manage DNS records in different DNS providers using external-dns. **Not all the record types are enabled by default so the required record types must be enabled by using `--managed-record-types`.** ```bash external-dns --source=crd \ --domain-filter=example.com \ --managed-record-types=A \ --managed-record-types=CNAME \ --managed-record-types=NS ``` * Example for record type `A` ```yaml apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: examplearecord spec: endpoints: - dnsName: example.com recordTTL: 60 recordType: A targets: - 10.0.0.1 ``` * Example for record type `CNAME` ```yaml apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: examplecnamerecord spec: endpoints: - dnsName: test-a.example.com recordTTL: 300 recordType: CNAME targets: - example.com ``` > **Note:** CNAME targets accept both bare hostnames (`example.com`) and absolute FQDNs with a trailing dot (`example.com.`), as defined by [RFC 1035 §5.1](https://www.rfc-editor.org/rfc/rfc1035#section-5.1). Other record types (A, AAAA, NS, etc.) do not accept a trailing dot. * Example for record type `NS` ```yaml apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: ns-record spec: endpoints: - dnsName: zone.example.com recordTTL: 300 recordType: NS targets: - ns1.example.com - ns2.example.com ``` ## RBAC configuration If you use RBAC, extend the `external-dns` ClusterRole with: ```yaml - apiGroups: ["externaldns.k8s.io"] resources: ["dnsendpoints"] verbs: ["get","watch","list"] - apiGroups: ["externaldns.k8s.io"] resources: ["dnsendpoints/status"] verbs: ["*"] ``` ================================================ FILE: docs/sources/f5-transportserver.md ================================================ # F5 Networks TransportServer Source This tutorial describes how to configure ExternalDNS to use the F5 Networks TransportServer Source. It is meant to supplement the other provider-specific setup tutorials. The F5 Networks TransportServer CRD is part of [this](https://github.com/F5Networks/k8s-bigip-ctlr) project. See more in-depth info regarding the [TransportServer CRD here](https://github.com/F5Networks/k8s-bigip-ctlr/tree/master/docs/cis-20.x/config_examples/customResource/TransportServer). ## Start with ExternalDNS with the F5 Networks TransportServer source 1. Make sure that you have the `k8s-bigip-ctlr` installed in your cluster. The needed CRDs are bundled within the controller. 2. In your Helm `values.yaml` add: ```yaml sources: - ... - f5-transportserver - ... ``` or add it in your `Deployment` if you aren't installing `external-dns` via Helm: ```yaml args: - --source=f5-transportserver ``` Note that, in case you're not installing via Helm, you'll need the following in the `ClusterRole` bound to the service account of `external-dns`: ```yaml - apiGroups: - cis.f5.com resources: - transportservers verbs: - get - list - watch ``` ### Example TransportServer CR w/ host in spec ```yaml apiVersion: cis.f5.com/v1 kind: TransportServer metadata: labels: f5cr: 'true' name: test-ts namespace: test-ns spec: bigipRouteDomain: 0 host: test.example.com ipamLabel: vips mode: standard pool: service: test-service servicePort: 4222 virtualServerPort: 4222 ``` ### Example TransportServer CR w/ target annotation set If the `external-dns.alpha.kubernetes.io/target` annotation is set, the record created will reflect that and everything else will be ignored. ```yaml apiVersion: cis.f5.com/v1 kind: TransportServer metadata: annotations: external-dns.alpha.kubernetes.io/target: 10.172.1.12 labels: f5cr: 'true' name: test-ts namespace: test-ns spec: bigipRouteDomain: 0 host: test.example.com ipamLabel: vips mode: standard pool: service: test-service servicePort: 4222 virtualServerPort: 4222 ``` ### Example TransportServer CR w/ VirtualServerAddress set If `virtualServerAddress` is set, the record created will reflect that. `external-dns.alpha.kubernetes.io/target` will take precedence though. ```yaml apiVersion: cis.f5.com/v1 kind: TransportServer metadata: labels: f5cr: 'true' name: test-ts namespace: test-ns spec: bigipRouteDomain: 0 host: test.example.com ipamLabel: vips mode: standard pool: service: test-service servicePort: 4222 virtualServerPort: 4222 virtualServerAddress: 10.172.1.123 ``` If there is no target annotation or `virtualServerAddress` field set, then it'll use the `VSAddress` field from the created TransportServer status to create the record. ================================================ FILE: docs/sources/f5-virtualserver.md ================================================ # F5 Networks VirtualServer Source This tutorial describes how to configure ExternalDNS to use the F5 Networks VirtualServer Source. It is meant to supplement the other provider-specific setup tutorials. The F5 Networks VirtualServer CRD is part of [this](https://github.com/F5Networks/k8s-bigip-ctlr) project. See more in-depth info regarding the VirtualServer CRD [in the official documentation](https://github.com/F5Networks/k8s-bigip-ctlr/blob/master/docs/config_examples/customResource/CustomResource.md#virtualserver). ## Start with ExternalDNS with the F5 Networks VirtualServer source 1. Make sure that you have the `k8s-bigip-ctlr` installed in your cluster. The needed CRDs are bundled within the controller. 2. In your Helm `values.yaml` add: ```yaml sources: - ... - f5-virtualserver - ... ``` or add it in your `Deployment` if you aren't installing `external-dns` via Helm: ```yaml args: - --source=f5-virtualserver ``` Note that, in case you're not installing via Helm, you'll need the following in the `ClusterRole` bound to the service account of `external-dns`: ```yaml - apiGroups: - cis.f5.com resources: - virtualservers verbs: - get - list - watch ``` ## How it works The F5 VirtualServer source creates DNS records based on the following fields: - **`spec.host`**: The primary hostname for the virtual server - **`spec.hostAliases`**: Additional hostnames that should also resolve to the same targets - **`spec.virtualServerAddress`**: The IP address to use as the target (if no target annotation is set) - **`status.vsAddress`**: The IP address from the status field (if no spec address or target annotation is set) ### Example VirtualServer with hostAliases ```yaml apiVersion: cis.f5.com/v1 kind: VirtualServer metadata: name: example-vs namespace: default spec: host: www.example.com hostAliases: - alias1.example.com - alias2.example.com virtualServerAddress: 192.168.1.100 ``` This configuration will create DNS A records for: - `www.example.com` → `192.168.1.100` - `alias1.example.com` → `192.168.1.100` - `alias2.example.com` → `192.168.1.100` ### Target Priority The source follows this priority order for determining targets: 1. **Target annotation**: `external-dns.alpha.kubernetes.io/target` (highest priority) 2. **Spec address**: `spec.virtualServerAddress` 3. **Status address**: `status.vsAddress` If none of these are available, the VirtualServer will be skipped. ### TTL Support You can set a custom TTL using the annotation: ```yaml annotations: external-dns.alpha.kubernetes.io/ttl: "300" ``` ### Annotation Filtering You can filter VirtualServers using the `--annotation-filter` flag to only process those with specific annotations. ================================================ FILE: docs/sources/gateway-api.md ================================================ # Gateway API Route Sources This describes how to configure ExternalDNS to use Gateway API Route sources. It is meant to supplement the other provider-specific setup tutorials. ## Supported API Versions ExternalDNS currently supports a mixture of v1alpha2, v1beta1, v1 APIs. Gateway API has two release channels: Standard and Experimental. The Experimental channel includes v1alpha2, v1beta2, and v1 APIs. The Standard channel only includes v1beta2 and v1 APIs, not v1alpha2. TCPRoutes, TLSRoutes, and UDPRoutes only exist in v1alpha2 and continued support for these versions is NOT guaranteed. At some time in the future, Gateway API will graduate these Routes to v1. ExternalDNS will likely follow that upgrade and move to the v1 API, where they will be available in the Standard release channel. This will be a breaking change if your Experimental CRDs are not updated to include the new v1 API. Gateways and HTTPRoutes are available in v1alpha2, v1beta1, and v1 APIs. However, some notable environments are behind in upgrading their CRDs to include the v1 API. For compatibility reasons Gateways and HTTPRoutes use the v1beta1 API. GRPCRoutes are available in v1alpha2 and v1 APIs, not v1beta2. Therefore, GRPCRoutes use the v1 API which is available in both release channels. Unfortunately, this means they will not be available in environments with old CRDs. ## Hostnames HTTPRoute and TLSRoute specs, along with their associated Gateway Listeners, contain hostnames that will be used by ExternalDNS. However, no such hostnames may be specified in TCPRoute or UDPRoute specs. For TCPRoutes and UDPRoutes, the `external-dns.alpha.kubernetes.io/hostname` annotation is the recommended way to provide their hostnames to ExternalDNS. This annotation is also supported for HTTPRoutes and TLSRoutes by ExternalDNS, but it's _strongly_ recommended that they use their specs to provide all intended hostnames, since the Gateway that ultimately routes their requests/connections won't recognize additional hostnames from the annotation. ## Annotations ### Annotation Placement ExternalDNS reads different annotations from different Gateway API resources: - **Gateway annotations**: Only `external-dns.alpha.kubernetes.io/target` is read from Gateway resources - **Route annotations**: All other annotations (hostname, ttl, controller, provider-specific) are read from Route resources (HTTPRoute, GRPCRoute, TLSRoute, TCPRoute, UDPRoute) This separation aligns with Gateway API architecture where Gateway defines infrastructure (IP addresses, listeners) and Routes define application-level DNS records. ### Examples #### Example: Cloudflare Proxied Records ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: my-gateway namespace: default annotations: # ✅ Correct: target annotation on Gateway external-dns.alpha.kubernetes.io/target: "203.0.113.1" spec: gatewayClassName: cilium listeners: - name: https hostname: "*.example.com" protocol: HTTPS port: 443 --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: my-route annotations: # ✅ Correct: provider-specific annotations on HTTPRoute external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" external-dns.alpha.kubernetes.io/ttl: "300" spec: parentRefs: - name: my-gateway namespace: default hostnames: - api.example.com rules: - backendRefs: - name: api-service port: 8080 ``` #### Example: AWS Route53 with Routing Policies ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: aws-gateway annotations: # ✅ Correct: target annotation on Gateway external-dns.alpha.kubernetes.io/target: "alb-123.us-east-1.elb.amazonaws.com" --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: weighted-route annotations: # ✅ Correct: AWS-specific annotations on HTTPRoute external-dns.alpha.kubernetes.io/aws-weight: "100" external-dns.alpha.kubernetes.io/set-identifier: "backend-v1" spec: parentRefs: - name: aws-gateway hostnames: - app.example.com ``` ### Common Mistakes ❌ **Incorrect**: Placing provider-specific annotations on Gateway ```yaml kind: Gateway metadata: annotations: # ❌ These annotations are ignored on Gateway external-dns.alpha.kubernetes.io/cloudflare-proxied: "true" external-dns.alpha.kubernetes.io/ttl: "300" ``` ❌ **Incorrect**: Placing target annotation on HTTPRoute ```yaml kind: HTTPRoute metadata: annotations: # ❌ This annotation is ignored on Routes external-dns.alpha.kubernetes.io/target: "203.0.113.1" ``` ### external-dns.alpha.kubernetes.io/gateway-hostname-source **Why is this needed:** In certain scenarios, conflicting DNS records can arise when External DNS processes both the hostname annotations and the hostnames defined in the `*Route` spec. For example: - A CNAME record (`company.public.example.com -> company.private.example.com`) is used to direct traffic to private endpoints (e.g., AWS PrivateLink). - Some third-party services require traffic to resolve publicly to the Gateway API load balancer, but the hostname (`company.public.example.com`) must remain unchanged to avoid breaking the CNAME setup. - Without this annotation, External DNS may override the CNAME record with an A record due to conflicting hostname definitions. **Usage:** By setting the annotation `external-dns.alpha.kubernetes.io/gateway-hostname-source: annotation-only`, users can instruct External DNS to ignore hostnames defined in the `HTTPRoute` spec and use only the hostnames specified in annotations. This ensures compatibility with complex DNS configurations and avoids record conflicts. **Example:** ```yaml apiVersion: gateway.networking.k8s.io/v1beta1 kind: HTTPRoute metadata: annotations: external-dns.alpha.kubernetes.io/gateway-hostname-source: annotation-only external-dns.alpha.kubernetes.io/hostname: company.private.example.com spec: hostnames: - company.public.example.com ``` In this example, External DNS will create DNS records only for `company.private.example.com` based on the annotation, ignoring the `hostnames` field in the `HTTPRoute` spec. This prevents conflicts with existing CNAME records while enabling public resolution for specific endpoints. For a complete list of supported annotations, see the [annotations documentation](../annotations/annotations.md#gateway-api-annotation-placement). ## Manifest with RBAC ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns namespace: default --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["namespaces"] verbs: ["get","watch","list"] - apiGroups: ["gateway.networking.k8s.io"] resources: ["gateways","httproutes","grpcroutes","tlsroutes","tcproutes","udproutes"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns namespace: default spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: # Add desired Gateway API Route sources. - --source=gateway-httproute - --source=gateway-grpcroute - --source=gateway-tlsroute - --source=gateway-tcproute - --source=gateway-udproute # Optionally, limit Routes to those in the given namespace. - --namespace=my-route-namespace # Optionally, limit Routes to those matching the given label selector. - --label-filter=my-route-label==my-route-value # Optionally, limit Route endpoints to those Gateways with the given name. - --gateway-name=my-gateway-name # Optionally, limit Route endpoints to those Gateways in the given namespace. - --gateway-namespace=my-gateway-namespace # Optionally, limit Route endpoints to those Gateways matching the given label selector. - --gateway-label-filter=my-gateway-label==my-gateway-value # Add provider-specific flags... - --domain-filter=external-dns-test.my-org.com - --provider=google - --registry=txt - --txt-owner-id=my-identifier ``` ================================================ FILE: docs/sources/gateway.md ================================================ # Gateway sources The gateway-grpcroute, gateway-httproute, gateway-tcproute, gateway-tlsroute, and gateway-udproute sources create DNS entries based on their respective `gateway.networking.k8s.io` resources. ## Filtering the Routes considered These sources support the `--label-filter` flag, which filters \*Route resources by a set of labels. ## Domain names To calculate the Domain names created from a *Route, this source first collects a set of [domain names from the *Route](#domain-names-from-route). It then iterates over each of the `status.parents` with a [matching Gateway](#matching-gateways) and at least one [matching listener](#matching-listeners). For each matching listener, if the listener has a `hostname`, it narrows the set of domain names from the \*Route to the portion that overlaps the `hostname`. If a matching listener does not have a `hostname`, it uses the un-narrowed set of domain names. ### Domain names from Route The set of domain names from a \*Route is sourced from the following places: - If the \*Route is a GRPCRoute, HTTPRoute, or TLSRoute, adds each of the`spec.hostnames`. - Adds the hostnames from any `external-dns.alpha.kubernetes.io/hostname` annotation on the \*Route. This behavior is suppressed if the `--ignore-hostname-annotation` flag was specified. - If no endpoints were produced by the previous steps or the `--combine-fqdn-annotation` flag was specified, then adds hostnames generated from any`--fqdn-template` flag. - If no endpoints were produced by the previous steps, each attached Gateway listener will use its `hostname`, if present. ### Matching Gateways Matching Gateways are discovered by iterating over the \*Route's `status.parents`: - Ignores parents with a `parentRef.group` other than `gateway.networking.k8s.io` or a `parentRef.kind` other than `Gateway`. - If the `--gateway-name` flag was specified, ignores parents with a `parentRef.name` other than the specified value. For example, given the following HTTPRoute: ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: echo spec: hostnames: - echoserver.example.org parentRefs: - group: networking.k8s.io kind: Gateway name: internal --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: echo2 spec: hostnames: - echoserver2.example.org parentRefs: - group: networking.k8s.io kind: Gateway name: external ``` And using the `--gateway-name=external` flag, only the `echo2` HTTPRoute will be considered for DNS entries. - If the `--gateway-namespace` flag was specified, ignores parents with a `parentRef.namespace` other than the specified value. - If the `--gateway-label-filter` flag was specified, ignores parents whose Gateway does not match the specified label filter. - Ignores parents whose Gateway either does not exist or has not accepted the route. ### Matching listeners Iterates over all listeners for the parent's `parentRef.sectionName`: - Ignores listeners whose `protocol` field does not match the kind of the \*Route per the following table: | kind | protocols | | --------- | ----------- | | GRPCRoute | HTTP, HTTPS | | HTTPRoute | HTTP, HTTPS | | TCPRoute | TCP | | TLSRoute | TLS | | UDPRoute | UDP | - If the parent's `parentRef.port` port is specified, ignores listeners without a matching `port`. - Ignores listeners which specify an `allowedRoutes` which does not allow the route. ## Targets The targets of the DNS entries created from a \*Route are sourced from the following places: 1. If a matching parent Gateway has an `external-dns.alpha.kubernetes.io/target` annotation, uses the values from that. 2. Otherwise, iterates over that parent Gateway's `status.addresses`, adding each address's `value`. The targets from each parent Gateway matching the \*Route are then combined and de-duplicated. ## Dualstack Routes Gateway resources may be served from an external-loadbalancer which may support both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces. When using the AWS Route53 provider, External DNS Controller will always create both A and AAAA alias DNS records by default, regardless of whether the load balancer is dual stack or not. ## Example ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: echo spec: hostnames: - echoserver.example.org rules: - backendRefs: - group: "" kind: Service name: echo port: 1027 weight: 1 matches: - path: type: PathPrefix value: /echo ``` ================================================ FILE: docs/sources/gloo-proxy.md ================================================ # Gloo Proxy Source This tutorial describes how to configure ExternalDNS to use the Gloo Proxy source. It is meant to supplement the other provider-specific setup tutorials. ## Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns # update this to the desired external-dns version image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=gloo-proxy - --gloo-namespace=custom-gloo-system # gloo system namespace. Specify multiple times for multiple namespaces. Omit to use the default (gloo-system) - --provider=aws - --registry=txt - --txt-owner-id=my-identifier ``` ## Manifest (for clusters with RBAC enabled) Could be change if you have mulitple sources ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list","watch"] - apiGroups: ["gloo.solo.io"] resources: ["proxies"] verbs: ["get","watch","list"] - apiGroups: ["gateway.solo.io"] resources: ["virtualservices"] verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns # update this to the desired external-dns version image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=gloo-proxy - --gloo-namespace=custom-gloo-system # gloo system namespace. Specify multiple times for multiple namespaces. Omit to use the default (gloo-system) - --provider=aws - --registry=txt - --txt-owner-id=my-identifier ``` ## Gateway Annotation To support setups where an Ingress resource is used to provision an external LB you can add the following annotation to your Gateway **Note:** The Ingress namespace can be omitted if its in the same namespace as the gateway ```bash $ cat < ExternalDNS supports multiple sources for discovering DNS records. Each source watches specific Kubernetes or cloud platform resources and generates DNS records based on their configuration. ## Overview Sources are responsible for: - Watching Kubernetes resources or external APIs - Extracting DNS information from annotations and resource specifications - Generating DNS endpoint records for providers to consume ## Available Sources | **Source Name** | Filters | Namespace | FQDN Template | Events | Provider Specific | Category | Resources | |:-------------------------|:-----------------|:-----------|:--------------|:-------|:------------------|:--------------------|:--------------------------------------------------------------------------------------| | **ambassador-host** | annotation,label | all,single | false | false | true | ingress controllers | Host.getambassador.io | | **connector** | | | false | false | false | special | Remote TCP Server | | **contour-httpproxy** | annotation | all,single | true | false | true | ingress controllers | HTTPProxy.projectcontour.io | | **crd** | annotation,label | all,single | false | true | true | externaldns | DNSEndpoint.externaldns.k8s.io | | **empty** | | | false | false | false | testing | None | | **f5-transportserver** | annotation | all,single | false | false | false | load balancers | TransportServer.cis.f5.com | | **f5-virtualserver** | annotation | all,single | false | false | false | load balancers | VirtualServer.cis.f5.com | | **fake** | | | true | true | false | testing | Fake Endpoints | | **gateway-grpcroute** | annotation,label | all,single | true | false | true | gateway api | GRPCRoute.gateway.networking.k8s.io | | **gateway-httproute** | annotation,label | all,single | true | false | true | gateway api | HTTPRoute.gateway.networking.k8s.io | | **gateway-tcproute** | annotation,label | all,single | true | false | true | gateway api | TCPRoute.gateway.networking.k8s.io | | **gateway-tlsroute** | annotation,label | all,single | true | false | true | gateway api | TLSRoute.gateway.networking.k8s.io | | **gateway-udproute** | annotation,label | all,single | true | false | true | gateway api | UDPRoute.gateway.networking.k8s.io | | **gloo-proxy** | | all,single | false | false | true | service mesh | Proxy.gloo.solo.io | | **ingress** | annotation,label | all,single | true | true | true | kubernetes core | Ingress | | **istio-gateway** | annotation | all,single | true | false | true | service mesh | Gateway.networking.istio.io | | **istio-virtualservice** | annotation | all,single | true | false | true | service mesh | VirtualService.networking.istio.io | | **kong-tcpingress** | annotation | all,single | false | false | true | ingress controllers | TCPIngress.configuration.konghq.com | | **node** | annotation,label | all | true | true | false | kubernetes core | Node | | **openshift-route** | annotation,label | all,single | true | false | true | openshift | Route.route.openshift.io | | **pod** | annotation,label | all,single | true | true | false | kubernetes core | Pod | | **service** | annotation,label | all,single | true | true | true | kubernetes core | Service | | **skipper-routegroup** | annotation | all,single | true | false | true | ingress controllers | RouteGroup.zalando.org | | **traefik-proxy** | annotation | all,single | false | false | true | ingress controllers | IngressRoute.traefik.io
IngressRouteTCP.traefik.io
IngressRouteUDP.traefik.io | | **unstructured** | annotation,label | all,single | true | false | false | custom resources | Unstructured | ## Usage To use a specific source, configure ExternalDNS with the `--source` flag: ```bash external-dns --source=service --source=ingress ``` Multiple sources can be combined to watch different resource types simultaneously. ## Source Categories - **Kubernetes Core**: Native Kubernetes resources (Service, Ingress, Pod, Node) - **ExternalDNS**: Native ExternalDNS resources - **Gateway API**: Kubernetes Gateway API resources (Gateway, HTTPRoute, etc.) - **Service Mesh**: Service mesh implementations (Istio, Gloo) - **Ingress Controllers**: Third-party ingress controller resources (Contour, Traefik, Ambassador, etc.) - **Load Balancers**: Load balancer specific resources (F5) - **OpenShift**: OpenShift specific resources (Route) - **Cloud Platforms**: Cloud platform integrations (Cloud Foundry) - **Wrappers**: Source wrappers that modify or combine other sources - **Special**: Special purpose sources (connector, empty) - **Testing**: Sources used for testing purposes ================================================ FILE: docs/sources/ingress.md ================================================ # Ingress source The ingress source creates DNS entries based on `Ingress.networking.k8s.io` resources. ## Filtering the Ingresses considered The `--ingress-class` flag filters Ingress resources by a set of ingress classes. The flag may be specified multiple times in order to allow multiple ingress classes. This source supports the `--label-filter` flag, which filters Ingress resources by a set of labels. ## Domain names The domain names of the DNS entries created from an Ingress are sourced from the following places: 1. Iterates over the Ingress's `spec.rules`, adding any non-empty `host`. This behavior is suppressed if the `--ignore-ingress-rules-spec` flag was specified or the Ingress had an `external-dns.alpha.kubernetes.io/ingress-hostname-source: annotation-only` annotation. 2. Iterates over the Ingress's `spec.tls`, adding each member of `hosts`. This behavior is suppressed if the `--ignore-ingress-tls-spec` flag was specified or the Ingress had an `external-dns.alpha.kubernetes.io/ingress-hostname-source: annotation-only` annotation, 3. Adds the hostnames from any `external-dns.alpha.kubernetes.io/hostname` annotation. This behavior is suppressed if the `--ignore-hostname-annotation` flag was specified or the Ingress had an `external-dns.alpha.kubernetes.io/ingress-hostname-source: defined-hosts-only` annotation. 4. If no DNS entries were produced for an Ingress by the previous steps or the `--combine-fqdn-annotation` flag was specified, then adds hostnames generated from any`--fqdn-template` flag. ## Targets The targets of the DNS entries created from an Ingress are sourced from the following places: 1. If the Ingress has an `external-dns.alpha.kubernetes.io/target` annotation, uses the values from that. 2. Otherwise, iterates over the Ingress's `status.loadBalancer.ingress`, adding each non-empty `ip` and `hostname`. ================================================ FILE: docs/sources/istio.md ================================================ # Istio Gateway / Virtual Service Source This tutorial describes how to configure ExternalDNS to use the Istio Gateway source. It is meant to supplement the other provider-specific setup tutorials. **Note:** Using the Istio Gateway source requires Istio >=1.0.0. **Note:** Currently supported versions are `1.25` and `1.26` with `v1beta1` stored version. - [Support status of Istio releases](https://istio.io/latest/docs/releases/supported-releases/) - Manifest (for clusters without RBAC enabled) - Manifest (for clusters with RBAC enabled) - Update existing ExternalDNS Deployment ## Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --source=istio-gateway # choose one - --source=istio-virtualservice # or both - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier ``` ## Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] - apiGroups: ["networking.istio.io"] resources: ["gateways", "virtualservices"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --source=istio-gateway - --source=istio-virtualservice - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier ``` ## Update existing ExternalDNS Deployment - For clusters with running `external-dns`, you can just update the deployment. - With access to the `kube-system` namespace, update the existing `external-dns` deployment. - Add a parameter to the arguments of the container to create dns entries with `--source=istio-gateway`. Execute the following command or update the argument. ```console kubectl patch deployment external-dns --type='json' \ -p='[{"op": "add", "path": "/spec/template/spec/containers/0/args/2", "value": "--source=istio-gateway" }]' ``` In case the setup uses a `clusterrole`, just append a new value to the enable the istio group. ```console kubectl patch clusterrole external-dns --type='json' \ -p='[{"op": "add", "path": "/rules/4", "value": { "apiGroups": [ "networking.istio.io"], "resources": ["gateways"],"verbs": ["get", "watch", "list" ]} }]' ``` ## Verify that Istio Gateway/VirtualService Source works Follow the [Istio ingress traffic tutorial](https://istio.io/docs/tasks/traffic-management/ingress/) to deploy a sample service that will be exposed outside of the service mesh. The following are relevant snippets from that tutorial. ### Install a sample service With automatic sidecar injection: ```bash kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.25/samples/httpbin/httpbin.yaml ``` Otherwise: ```bash kubectl apply -f <(istioctl kube-inject -f https://raw.githubusercontent.com/istio/istio/release-1.25/samples/httpbin/httpbin.yaml) ``` ### Using a Gateway as a source #### Create an Istio Gateway ```bash $ cat < **ATTENTION**: Make sure to specify those whose account is related to the DNS record. - Successful executions will print the following ```console time="2020-01-17T06:08:08Z" level=info msg="Desired change: CREATE httpbin.example.com A" time="2020-01-17T06:08:08Z" level=info msg="Desired change: CREATE httpbin.example.com TXT" time="2020-01-17T06:08:08Z" level=info msg="2 record(s) in zone example.com. were successfully updated" time="2020-01-17T06:09:08Z" level=info msg="All records are already up to date, there are no changes for the matching hosted zones" ``` - If there's any problem around `clusterrole`, you would see the errors showing wrong permissions: ```console source \"gateways\" in API group \"networking.istio.io\" at the cluster scope" time="2020-01-17T06:07:08Z" level=error msg="gateways.networking.istio.io is forbidden: User \"system:serviceaccount:kube-system:external-dns\" cannot list resource \"gateways\" in API group \"networking.istio.io\" at the cluster scope" ``` ================================================ FILE: docs/sources/kong.md ================================================ # Kong TCPIngress Source This tutorial describes how to configure ExternalDNS to use the Kong TCPIngress source. It is meant to supplement the other provider-specific setup tutorials. ## Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns # update this to the desired external-dns version image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=kong-tcpingress - --provider=aws - --registry=txt - --txt-owner-id=my-identifier ``` ## Manifest (for clusters with RBAC enabled) Could be changed if you have mulitple sources ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list","watch"] - apiGroups: ["configuration.konghq.com"] resources: ["tcpingresses"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns # update this to the desired external-dns version image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=kong-tcpingress - --provider=aws - --registry=txt - --txt-owner-id=my-identifier ``` ================================================ FILE: docs/sources/mx-record.md ================================================ # MX record with CRD source You can create and manage MX records with the help of [CRD source](../sources/crd.md) and `DNSEndpoint` CRD. Currently, this feature is only supported by `aws`, `azure`, `cloudflare`, `google` and `webhook` providers. In order to start managing MX records you need to set the `--managed-record-types=MX` flag. ```console external-dns --source crd --provider {aws|azure|google} --managed-record-types=A --managed-record-types=CNAME --managed-record-types=MX ``` Targets within the CRD need to be specified according to the RFC 1034 (section 3.6.1). Below is an example of `example.com` DNS MX record which specifies two separate targets with distinct priorities. ```yaml apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: examplemxrecord spec: endpoints: - dnsName: example.com recordTTL: 180 recordType: MX targets: - 10 mailhost1.example.com - 20 mailhost2.example.com ``` ================================================ FILE: docs/sources/nodes.md ================================================ # Cluster Nodes as Source This tutorial describes how to configure ExternalDNS to use the cluster nodes as source. Using nodes (`--source=node`) as source is possible to synchronize a DNS zone with the nodes of a cluster. The node source adds an `A` record per each node `externalIP` (if not found, any IPv4 `internalIP` is used instead). It also adds an `AAAA` record per each node IPv6 `internalIP`. Refer to the [IPv6 Behavior](#ipv6-behavior) section for more details. The TTL of the records can be set with the `external-dns.alpha.kubernetes.io/ttl` node annotation. Nodes marked as **Unschedulable** as per [core/v1/NodeSpec](https://pkg.go.dev/k8s.io/api@v0.31.1/core/v1#NodeSpec) are excluded by default. As such, no DNS records are created for Unhealthy, NotReady or SchedulingDisabled (cordon) nodes (and existing ones are removed). In case you want to override the default, for example if you manage per-host DNS records via ExternalDNS, you can specify `--no-exclude-unschedulable` to always expose nodes no matter their status. ## IPv6 Behavior By default, ExternalDNS exposes the IPv6 `ExternalIP` of the nodes. If needed, one can still explicitly expose the internal ipv6 addresses by using the `--expose-internal-ipv6` flag. ### Example spec ```yaml spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 # update this to the desired external-dns version args: - --source=node # will use nodes as source - --provider=aws - --zone-name-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --domain-filter=external-dns-test.my-org.com - --aws-zone-type=public - --registry=txt - --fqdn-template={{.Name}}.external-dns-test.my-org.com - --txt-owner-id=my-identifier - --policy=sync - --log-level=debug ``` ## Manifest (for cluster without RBAC enabled) ```yaml --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=node # will use nodes as source - --provider=aws - --zone-name-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --domain-filter=external-dns-test.my-org.com - --aws-zone-type=public - --registry=txt - --fqdn-template={{.Name}}.external-dns-test.my-org.com - --txt-owner-id=my-identifier - --policy=sync - --log-level=debug ``` ## Manifest (for cluster with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: ["route.openshift.io"] resources: ["routes"] verbs: ["get", "watch", "list"] - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["get", "watch", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: external-dns --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=node # will use nodes as source - --provider=aws - --zone-name-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --domain-filter=external-dns-test.my-org.com - --aws-zone-type=public - --registry=txt - --fqdn-template={{.Name}}.external-dns-test.my-org.com - --txt-owner-id=my-identifier - --policy=sync - --log-level=debug ``` ================================================ FILE: docs/sources/ns-record.md ================================================ # NS record with CRD source You can create NS records with the help of [CRD source](../sources/crd.md) and `DNSEndpoint` CRD. In order to start managing NS records you need to set the `--managed-record-types=NS` flag. ```console external-dns --source crd --managed-record-types=A --managed-record-types=CNAME --managed-record-types=NS ``` Consider the following example ```yaml apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: ns-record spec: endpoints: - dnsName: zone.example.com recordTTL: 300 recordType: NS targets: - ns1.example.com - ns2.example.com ``` After instantiation of this Custom Resource external-dns will create NS record with the help of configured provider, e.g. `aws` ================================================ FILE: docs/sources/openshift.md ================================================ # OpenShift Route Source This tutorial describes how to configure ExternalDNS to use the OpenShift Route source. It is meant to supplement the other provider-specific setup tutorials. ## For OCP 4.x In OCP 4.x, if you have multiple [OpenShift ingress controllers](https://docs.openshift.com/container-platform/4.9/networking/ingress-operator.html) then you must specify an ingress controller name (also called router name), you can get it from the route's `status.ingress[*].routerName` field. If you don't specify a router name when you have multiple ingress controllers in your cluster then the first router from the route's `status.ingress` will be used. Note that the router must have admitted the route in order to be selected. Once the router is known, ExternalDNS will use this router's canonical hostname as the target for the CNAME record. Starting from OCP 4.10 you can use [ExternalDNS Operator](https://github.com/openshift/external-dns-operator) to manage ExternalDNS instances. Example of its custom resource for AWS provider: ```yaml apiVersion: externaldns.olm.openshift.io/v1alpha1 kind: ExternalDNS metadata: name: sample spec: provider: type: AWS source: openshiftRouteOptions: routerName: default type: OpenShiftRoute zones: - Z05387772BD5723IZFRX3 ``` This will create an ExternalDNS POD with the following container args in `external-dns` namespace: ```yaml spec: containers: - args: - --metrics-address=127.0.0.1:7979 - --txt-owner-id=external-dns-sample - --provider=aws - --source=openshift-route - --policy=sync - --registry=txt - --log-level=debug - --zone-id-filter=Z05387772BD5723IZFRX3 - --openshift-router-name=default - --txt-prefix=external-dns- ``` ## For OCP 3.11 environment ### Prepare ROUTER_CANONICAL_HOSTNAME in default/router deployment Read and go through [Finding the Host Name of the Router](https://docs.openshift.com/container-platform/3.11/install_config/router/default_haproxy_router.html#finding-router-hostname). If no ROUTER_CANONICAL_HOSTNAME is set, you must annotate each route with external-dns.alpha.kubernetes.io/target! ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=openshift-route - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] - apiGroups: ["route.openshift.io"] resources: ["routes"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=openshift-route - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier ``` ### Verify External DNS works (OpenShift Route example) The following instructions are based on the [Hello Openshift](https://github.com/openshift/origin/tree/HEAD/examples/hello-openshift). #### Install a sample service and expose it ```bash $ oc apply -f - < **Note**: Prefer built-in sources when available (e.g., `istio-virtualservice`, `gateway-httproute`) as they provide optimized handling for those resource types. ### Advanced Use Cases The unstructured source can also be used with: **Knative Service** - Serverless workloads expose auto-generated URLs in `.status.url` ```yaml status: url: https://hello.default.example.com ``` **Argo Rollouts** - Canary/blue-green deployments with preview services in `.status.canary.stableRS` ```yaml status: canary: stableRS: my-app-stable-abc123 ``` **Linkerd ServiceProfile** - Service mesh with destination overrides in `.spec.dstOverrides` ```yaml spec: dstOverrides: - authority: webapp.default.svc.cluster.local ``` **Crossplane Composition outputs** - Any Crossplane-managed cloud resource (ElastiCache, S3 websites, CloudFront, etc.) ```yaml status: atProvider: configurationEndpoint: address: my-cache.abc123.cache.amazonaws.com ``` **Cilium BGP PeeringPolicy** - BGP-advertised IPs for LoadBalancer services ```yaml status: conditions: - type: Established status: "True" ``` **ACK FieldExport** - AWS Controllers for Kubernetes can export resource status (RDS endpoints, S3 bucket URLs) to ConfigMaps via FieldExport, enabling dynamic DNS records ```yaml # FieldExport copies S3 bucket URL to ConfigMap apiVersion: services.k8s.aws/v1alpha1 kind: FieldExport spec: from: path: ".status.location" resource: group: s3.services.k8s.aws kind: Bucket name: my-bucket to: kind: configmap name: bucket-dns ``` ## Configuration | Flag | Description | |-----------------------------|--------------------------------------------------------------------| | `--unstructured-resource` | Resources to watch in `resource.version.group` format (repeatable) | | `--fqdn-template` | Go template for DNS names | | `--target-template` | Go template for DNS targets | | `--fqdn-target-template` | Go template returning `host:target` pairs | | `--label-filter` | Filter resources by labels | | `--annotation-filter` | Filter resources by annotations | | `--combine-fqdn-annotation` | Combine FQDN template and Annotations instead of overwriting | ## Template Syntax Templates have access to typed-style fields and raw object data: | Field | Description | |----------------|----------------------| | `.Name` | Object name | | `.Namespace` | Object namespace | | `.Kind` | Object kind | | `.APIVersion` | API version | | `.Labels` | Object labels | | `.Annotations` | Object annotations | | `.Metadata` | Raw metadata section | | `.Spec` | Raw spec section | | `.Status` | Raw status section | | `.Object` | Raw full object | ## Examples ### ConfigMap DNS Registry Use ConfigMaps as a lightweight DNS registry without needing custom CRDs. Useful for GitOps workflows where teams manage DNS entries via ConfigMaps in their namespaces. ```yaml apiVersion: v1 kind: ConfigMap metadata: name: api-dns namespace: production labels: external-dns.alpha.kubernetes.io/dns-controller: "dns-controller" data: hostname: api.example.com target: 10.0.0.100 ``` ```bash external-dns \ --source=unstructured \ --unstructured-resource=configmaps.v1 \ --fqdn-template='{{index .Object.data "hostname"}}' \ --target-template='{{index .Object.data "target"}}' \ --label-filter='external-dns.alpha.kubernetes.io/controller=dns-controller' # Result: # api.example.com -> 10.0.0.100 (A) ``` ### Crossplane RDS Instance ```bash external-dns \ --source=unstructured \ --unstructured-resource=rdsinstances.v1alpha1.rds.aws.crossplane.io \ --fqdn-template='{{.Name}}.db.example.com' \ --target-template='{{.Status.atProvider.endpoint.address}}' ``` ### Multiple Resources ```bash external-dns \ --source=unstructured \ --unstructured-resource=virtualmachineinstances.v1.kubevirt.io \ --unstructured-resource=rdsinstances.v1alpha1.rds.aws.crossplane.io \ --fqdn-template='{{.Name}}.{{.Kind}}.example.com' \ --target-template='{{.Status.endpoint}}' ``` ### MetalLB IPAddressPool ```yaml apiVersion: metallb.io/v1beta1 kind: IPAddressPool metadata: name: production-pool namespace: metallb-system annotations: external-dns.alpha.kubernetes.io/hostname: "lb.example.com" spec: addresses: - 192.168.10.11/32 ``` ```bash external-dns \ --source=unstructured \ --unstructured-resource=ipaddresspools.v1beta1.metallb.io \ --fqdn-template='{{index .Annotations "external-dns.alpha.kubernetes.io/hostname"}}' \ --target-template='{{$addr := index .Spec.addresses 0}}{{if contains $addr "/32"}}{{trimSuffix $addr "/32"}}{{else}}{{$addr}}{{end}}' # Result: # lb.example.com -> 192.168.10.11 (A) ``` > **Tip**: Use `contains` with `trimSuffix` to extract the IP from `/32` CIDR notation. ### Apache APISIX Route ```yaml apiVersion: apisix.apache.org/v2 kind: ApisixRoute metadata: name: httpbin namespace: ingress-apisix spec: http: - name: httpbin match: hosts: - httpbin.example.com paths: - /ip backends: - serviceName: httpbin servicePort: 80 status: apisix: gateway: apisix-gateway.ingress-apisix.svc.cluster.local ``` ```bash external-dns \ --source=unstructured \ --unstructured-resource=apisixroutes.v2.apisix.apache.org \ --fqdn-template='{{.Name}}.route.example.com' \ --target-template='{{.Status.apisix.gateway}}' # Result: # httpbin.route.example.com -> apisix-gateway.ingress-apisix.svc.cluster.local (CNAME) ``` ### cert-manager Certificate ```yaml apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: my-app-tls namespace: production annotations: external-dns.alpha.kubernetes.io/target: "10.0.0.50" spec: secretName: my-app-tls-secret dnsNames: - my-app.example.com - www.my-app.example.com issuerRef: name: letsencrypt-prod kind: ClusterIssuer ``` ```bash external-dns \ --source=unstructured \ --unstructured-resource=certificates.v1.cert-manager.io \ --fqdn-template='{{index .Spec.dnsNames 0}}' \ --target-template='{{index .Annotations "external-dns.alpha.kubernetes.io/target"}}' # Result: # my-app.example.com -> 10.0.0.50 (A) ``` ### Rancher Node ```yaml apiVersion: management.cattle.io/v3 kind: Node metadata: name: my-node-1 namespace: cattle-system labels: cattle.io/creator: norman node-role.kubernetes.io/controlplane: "true" spec: clusterName: c-abcde hostname: my-node-1 status: nodeName: worker-01 internalNodeStatus: addresses: - type: ExternalIP address: 203.0.113.10 ``` ```bash external-dns \ --source=unstructured \ --unstructured-resource=nodes.v3.management.cattle.io \ --fqdn-template='{{.Spec.hostname}}.nodes.example.com' \ --target-template='{{(index .Status.internalNodeStatus.addresses 0).address}}' \ --label-filter='node-role.kubernetes.io/controlplane=true' # Result: # my-node-1.nodes.example.com -> 203.0.113.10 (A) ``` ### ACK FieldExport with ConfigMap Use AWS Controllers for Kubernetes (ACK) to dynamically populate ConfigMaps with resource endpoints. FieldExport copies values from ACK-managed resources (RDS, S3, ElastiCache) to ConfigMaps, which external-dns can then use for DNS records. ```yaml # 1. ACK creates an S3 bucket apiVersion: s3.services.k8s.aws/v1alpha1 kind: Bucket metadata: name: app-assets namespace: default spec: name: my-app-assets-bucket --- # 2. FieldExport copies the bucket URL to a ConfigMap apiVersion: services.k8s.aws/v1alpha1 kind: FieldExport metadata: name: export-bucket-url namespace: default spec: from: path: ".status.location" resource: group: s3.services.k8s.aws kind: Bucket name: app-assets to: kind: configmap name: app-assets-dns namespace: default --- # 3. ConfigMap is populated by FieldExport apiVersion: v1 kind: ConfigMap metadata: name: app-assets-dns namespace: default labels: app.kubernetes.io/managed-by: ack-fieldexport data: default.export-bucket-url: "https://my-app-assets-bucket.s3.amazonaws.com/" ``` ```bash external-dns \ --source=unstructured \ --unstructured-resource=configmaps.v1 \ --fqdn-template='{{if eq .Kind "ConfigMap"}}{{.Name}}.cdn.example.com{{end}}' \ --target-template='{{if eq .Kind "ConfigMap"}}{{$url := index .Object.data "default.export-bucket-url"}}{{trimSuffix (trimPrefix $url "https://") "/"}}{{end}}' \ --label-filter='app.kubernetes.io/managed-by=ack-fieldexport' # Result: # app-assets-dns.cdn.example.com -> my-app-assets-bucket.s3.amazonaws.com (CNAME) ``` ### EndpointSlice for Headless Services Create per-pod DNS records from EndpointSlice resources for headless services. Each pod gets its own DNS entry pointing to its IP address. ```yaml apiVersion: discovery.k8s.io/v1 kind: EndpointSlice metadata: name: test-abc12 namespace: default labels: endpointslice.kubernetes.io/managed-by: endpointslice-controller.k8s.io kubernetes.io/service-name: test-headless service.kubernetes.io/headless: "" addressType: IPv4 endpoints: - addresses: - 10.244.1.2 conditions: ready: true nodeName: worker1 targetRef: kind: Pod name: app-abc12 namespace: default - addresses: - 10.244.2.3 - 10.244.2.4 conditions: ready: true nodeName: worker2 targetRef: kind: Pod name: app-def34 namespace: default ports: - name: http port: 80 protocol: TCP ``` ```bash external-dns \ --source=unstructured \ --unstructured-resource=endpointslices.v1.discovery.k8s.io \ --fqdn-target-template='{{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}}{{range $ep := .Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$ep.targetRef.name}}.pod.com:{{.}},{{end}}{{end}}{{end}}{{end}}' \ --fqdn-target-template='{{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}}{{$svcName := index .Labels "kubernetes.io/service-name"}}{{range $ep :=.Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$svcName}}.example.com:{{.}},{{end}}{{end}}{{end}}{{end}}' # Result: # app-abc12.pod.com -> 10.244.1.2 (A) # app-def34.pod.com -> 10.244.2.3, 10.244.2.4 (A) # test-abc12.example.com -> 10.244.1.2, 10.244.2.3, 10.244.2.4 (A) ``` The `--fqdn-target-template` flag returns `host:target` pairs, enabling 1:1 mapping between hostnames and targets. Useful when a Kubernetes resource contains arrays where each element should produce its own DNS record (e.g., EndpointSlice endpoints, multi-host configurations). ## RBAC Grant external-dns access to your custom resources: ```yaml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: # Add for each resource type - apiGroups: ["rds.aws.crossplane.io"] resources: ["rdsinstances"] verbs: ["get", "watch", "list"] - apiGroups: [""] resources: [""] verbs: ["get", "watch", "list"] ``` ================================================ FILE: docs/tutorials/akamai-edgedns.md ================================================ # Akamai Edge DNS ## Prerequisites External-DNS v0.8.0 or greater. ### Zones External-DNS manages service endpoints in existing DNS zones. The Akamai provider does not add, remove or configure new zones. The [Akamai Control Center](https://control.akamai.com) or [Akamai DevOps Tools](https://developer.akamai.com/devops), [Akamai CLI](https://developer.akamai.com/cli) and [Akamai Terraform Provider](https://developer.akamai.com/tools/integrations/terraform) can create and manage Edge DNS zones. ### Akamai Edge DNS Authentication The Akamai Edge DNS provider requires valid Akamai Edgegrid API authentication credentials to access zones and manage DNS records. Either directly by key or indirectly via a file can set credentials for the provider. The Akamai credential keys and mappings to the Akamai provider utilizing different presentation methods are: | Edgegrid Auth Key | External-DNS Cmd Line Key | Environment/ConfigMap Key | Description | |-------------------|------------------------------|-------------------------------------------|-----------------------------------| | host | akamai-serviceconsumerdomain | EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN | Akamai Edgegrid API server | | access_token | akamai-access-token | EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN | Akamai Edgegrid API access token | | client_token | akamai-client-token | EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN | Akamai Edgegrid API client token | | client-secret | akamai-client-secret | EXTERNAL_DNS_AKAMAI_CLIENT_SECRET | Akamai Edgegrid API client secret | In addition to specifying auth credentials individually, an Akamai Edgegrid .edgerc file convention can set credentials. | External-DNS Cmd Line | Environment/ConfigMap | Description | |-----------------------|------------------------------------|----------------------------------------------------------------------| | akamai-edgerc-path | EXTERNAL_DNS_AKAMAI_EDGERC_PATH | Accessible path to Edgegrid credentials file, e.g /home/test/.edgerc | | akamai-edgerc-section | EXTERNAL_DNS_AKAMAI_EDGERC_SECTION | Section in Edgegrid credentials file containing credentials | [Akamai API Authentication](https://developer.akamai.com/getting-started/edgegrid) provides an overview and further information about authorization credentials for API base applications and tools. ## Deploy External-DNS An operational External-DNS deployment consists of an External-DNS container and service. The following sections demonstrate the ConfigMap objects that would make up an example functional external DNS kubernetes configuration utilizing NGINX as the service. Connect your `kubectl` client to the External-DNS cluster. Begin by creating a Kubernetes secret to securely store your Akamai Edge DNS Access Tokens. This key will enable ExternalDNS to authenticate with Akamai Edge DNS: ```shell kubectl create secret generic AKAMAI-DNS --from-literal=EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN=YOUR_SERVICECONSUMERDOMAIN --from-literal=EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN=YOUR_CLIENT_TOKEN --from-literal=EXTERNAL_DNS_AKAMAI_CLIENT_SECRET=YOUR_CLIENT_SECRET --from-literal=EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN=YOUR_ACCESS_TOKEN ``` Ensure to replace YOUR_SERVICECONSUMERDOMAIN, EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN, YOUR_CLIENT_SECRET and YOUR_ACCESS_TOKEN with your actual Akamai Edge DNS API keys. Then apply one of the following manifests file to deploy ExternalDNS. ### Using Helm Create a values.yaml file to configure ExternalDNS to use Akamai Edge DNS as the DNS provider. This file should include the necessary environment variables: ```shell provider: name: akamai env: - name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN - name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN - name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET - name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN ``` Finally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file: ```shell helm upgrade --install external-dns external-dns/external-dns --values values.yaml ``` ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # or ingress or both - --provider=akamai - --domain-filter=example.com # zone-id-filter may be specified as well to filter on contract ID - --registry=txt - --txt-owner-id={{ owner-id-for-this-external-dns }} - --txt-prefix={{ prefix label for TXT record }}. env: - name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN - name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN - name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET - name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["watch", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # or ingress or both - --provider=akamai - --domain-filter=example.com # zone-id-filter may be specified as well to filter on contract ID - --registry=txt - --txt-owner-id={{ owner-id-for-this-external-dns }} - --txt-prefix={{ prefix label for TXT record }}. env: - name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN - name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN - name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET - name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN valueFrom: secretKeyRef: name: AKAMAI-DNS key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN ``` Create the deployment for External-DNS: ```sh kubectl apply -f externaldns.yaml ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.example.com external-dns.alpha.kubernetes.io/ttl: "600" #optional spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Create the deployment and service object: ```sh kubectl apply -f nginx.yaml ``` ## Verify Akamai Edge DNS Records Wait 3-5 minutes before validating the records to allow the record changes to propagate to all the Akamai name servers. Validate records using the [Akamai Control Center](http://control.akamai.com) or by executing a dig, nslookup or similar DNS command. ## Cleanup Once you successfully configure and verify record management via External-DNS, you can delete the tutorial's examples: ```sh kubectl delete -f nginx.yaml kubectl delete -f externaldns.yaml ``` ## Additional Information * The Akamai provider allows the administrative user to filter zones by both name (`domain-filter`) and contract Id (`zone-id-filter`). The Edge DNS API will return a '500 Internal Error' for invalid contract Ids. * The provider will substitute quotes in TXT records with a `` ` `` (back tick) when writing records with the API. ================================================ FILE: docs/tutorials/alibabacloud.md ================================================ # Alibaba Cloud This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster on Alibaba Cloud. Make sure to use **>=0.5.6** version of ExternalDNS for this tutorial ## RAM Permissions ```json { "Version": "1", "Statement": [ { "Action": "alidns:AddDomainRecord", "Resource": "*", "Effect": "Allow" }, { "Action": "alidns:DeleteDomainRecord", "Resource": "*", "Effect": "Allow" }, { "Action": "alidns:UpdateDomainRecord", "Resource": "*", "Effect": "Allow" }, { "Action": "alidns:DescribeDomainRecords", "Resource": "*", "Effect": "Allow" }, { "Action": "alidns:DescribeDomains", "Resource": "*", "Effect": "Allow" }, { "Action": "pvtz:AddZoneRecord", "Resource": "*", "Effect": "Allow" }, { "Action": "pvtz:DeleteZoneRecord", "Resource": "*", "Effect": "Allow" }, { "Action": "pvtz:UpdateZoneRecord", "Resource": "*", "Effect": "Allow" }, { "Action": "pvtz:DescribeZoneRecords", "Resource": "*", "Effect": "Allow" }, { "Action": "pvtz:DescribeZones", "Resource": "*", "Effect": "Allow" }, { "Action": "pvtz:DescribeZoneInfo", "Resource": "*", "Effect": "Allow" } ] } ``` When running on Alibaba Cloud, you need to make sure that your nodes (on which External DNS runs) have the RAM instance profile with the above RAM role assigned. ## Set up a Alibaba Cloud DNS service or Private Zone service Alibaba Cloud DNS Service is the domain name resolution and management service for public access. It routes access from end-users to the designated web app. Alibaba Cloud Private Zone is the domain name resolution and management service for VPC internal access. *If you prefer to try-out ExternalDNS in one of the existing domain or zone you can skip this step* Create a DNS domain which will contain the managed DNS records. For public DNS service, the domain name should be valid and owned by yourself. ```console aliyun alidns AddDomain --DomainName "external-dns-test.com" ``` Make a note of the ID of the hosted zone you just created. ```console aliyun alidns DescribeDomains --KeyWord="external-dns-test.com" | jq -r '.Domains.Domain[0].DomainId' ``` ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=external-dns-test.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=alibabacloud - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --alibaba-cloud-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier volumeMounts: - mountPath: /usr/share/zoneinfo name: hostpath volumes: - name: hostpath hostPath: path: /usr/share/zoneinfo type: Directory ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=external-dns-test.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=alibabacloud - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --alibaba-cloud-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier - --alibaba-cloud-config-file= # enable sts token volumeMounts: - mountPath: /usr/share/zoneinfo name: hostpath volumes: - name: hostpath hostPath: path: /usr/share/zoneinfo type: Directory ``` ## Arguments This list is not the full list, but a few arguments that where chosen. ### alibaba-cloud-zone-type `alibaba-cloud-zone-type` allows filtering for private and public zones * If value is `public`, it will sync with records in Alibaba Cloud DNS Service * If value is `private`, it will sync with records in Alibaba Cloud Private Zone Service ## Verify ExternalDNS works (Ingress example) Create an ingress resource manifest file. > For ingress objects ExternalDNS will create a DNS record based on the host specified for the ingress object. ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: foo spec: ingressClassName: nginx # use the one that corresponds to your ingress controller. rules: - host: foo.external-dns-test.com http: paths: - backend: service: name: foo port: number: 80 pathType: Prefix ``` ## Verify ExternalDNS works (Service example) Create the following sample application to test that ExternalDNS works. > For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value. ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.com. spec: type: LoadBalancer ports: - port: 80 name: http targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 name: http ``` After roughly two minutes check that a corresponding DNS record for your service was created. ```console $ aliyun alidns DescribeDomainRecords --DomainName=external-dns-test.com { "PageNumber": 1, "TotalCount": 1, "PageSize": 20, "RequestId": "1DBEF426-F771-46C7-9802-4989E9C94EE8", "DomainRecords": { "Record": [ { "RR": "nginx", "Status": "ENABLE", "Value": "1.2.3.4", "Weight": 1, "RecordId": "3994015629411328", "Type": "A", "DomainName": "external-dns-test.com", "Locked": false, "Line": "default", "TTL": 600 }, { "RR": "nginx", "Status": "ENABLE", "Value": "heritage=external-dns;external-dns/owner=my-identifier", "Weight": 1, "RecordId": "3994015629411329", "Type": "TTL", "DomainName": "external-dns-test.com", "Locked": false, "Line": "default", "TTL": 600 } ] } } ``` Note created TXT record alongside ALIAS record. TXT record signifies that the corresponding ALIAS record is managed by ExternalDNS. This makes ExternalDNS safe for running in environments where there are other records managed via other means. Let's check that we can resolve this DNS name. We'll ask the nameservers assigned to your zone first. ```console dig nginx.external-dns-test.com. ``` If you hooked up your DNS zone with its parent zone correctly you can use `curl` to access your site. ```console $ curl nginx.external-dns-test.com. Welcome to nginx! ... ... ``` ## Custom TTL The default DNS record TTL (Time-To-Live) is 300 seconds. You can customize this value by setting the annotation `external-dns.alpha.kubernetes.io/ttl`. e.g., modify the service manifest YAML file above: ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.com external-dns.alpha.kubernetes.io/ttl: 60 spec: ... ``` This will set the DNS record's TTL to 60 seconds. ## Clean up Make sure to delete all Service objects before terminating the cluster so all load balancers get cleaned up correctly. ```console kubectl delete service nginx ``` Give ExternalDNS some time to clean up the DNS records for you. Then delete the hosted zone if you created one for the testing purpose. ```console aliyun alidns DeleteDomain --DomainName external-dns-test.com ``` For more info about Alibaba Cloud external dns, please refer this [docs](https://yq.aliyun.com/articles/633412) ================================================ FILE: docs/tutorials/anexia-engine.md ================================================ # Anexia Official documentation of how to use `external-dns` in combination with Anexia Engine can be viewed on the official documentation pages. These guides include an overview of Anexia CloudDNS and integration with ExternalDNS. * [User Guide - Kubernetes and CloudDNS](https://engine.anexia-it.com/docs/en/module/kubernetes/user-guide/kubernetes-and-clouddns) * [Getting Started - Setting up an ExternalDNS Webhook](https://engine.anexia-it.com/docs/en/module/kubernetes/getting-started/setting-up-an-externaldns-webhook) ================================================ FILE: docs/tutorials/aws-filters.md ================================================ # AWS Filters This document provides guidance on filtering AWS zones using various strategies and flags. ## Strategies for Scoping Zones > Without specifying these flags, management applies to all zones. In order to manage specific zones, there is a possibility to combine multiple options | Argument | Description | Flow Control | |:---------------------------|:-----------------------------------------------------------|:------------:| | `--zone-id-filter` | Specify multiple times if needed | OR | | `--domain-filter` | By domain suffix - specify multiple times if needed | OR | | `--regex-domain-filter` | By domain suffix but as a regex - overrides domain-filter | AND | | `--exclude-domains` | To exclude a domain or subdomain | OR | | `--regex-domain-exclusion` | Subtracts its matches from `regex-domain-filter`'s matches | AND | | `--aws-zone-type` | Only sync zones of this type `[public\|private]` | OR | | `--aws-zone-tags` | Only sync zones with this tag | AND | Minimum required configuration ```sh args: --provider=aws --registry=txt --source=service ``` ### Filter by Zone Type > If this flag is not specified, management applies to both public and private zones. ```sh args: --aws-zone-type=private|public # choose between public or private ... ``` ### Filter by Domain > Specify multiple times if needed. ```sh args: --domain-filter=example.com --domain-filter=.paradox.example.com ... ``` Example `--domain-filter=example.com` will allow for zone `example.com` and any zones that end in `.example.com`, including `an.example.com`, i.e., the subdomains of example.com. When there are multiple domains, filter `--domain-filter=example.com` will match domains `example.com`, `ex.par.example.com`, `par.example.com`, `x.par.eu-west-1.example.com`. And if the filter is prepended with `.` e.g., `--domain-filter=.example.com` it will allow *only* zones that end in `.example.com`, i.e., the subdomains of example.com but not the `example.com` zone itself. Example result: `ex.par.eu-west-1.example.com`, `ex.par.example.com`, `par.example.com`. > Note: if you prepend the filter with ".", it will not attempt to match parent zones. ### Filter by Zone ID > Specify multiple times if needed, the flow logic is OR ```sh args: --zone-id-filter=ABCDEF12345678 --zone-id-filter=XYZDEF12345888 ... ``` ### Filter by Tag > Specify multiple times if needed, the flow logic is AND Keys only ```sh args: --aws-zone-tags=owner --aws-zone-tags=vertical ``` Or specify keys with values ```sh args: --aws-zone-tags=owner=k8s --aws-zone-tags=vertical=k8s ``` Can't specify multiple or separate values with commas: `key1=val1,key2=val2` at the moment. Filter only by value `--aws-zone-tags==tag-value` is not supported. ```sh args: --aws-zone-tags=team=k8s,vertical=platform # this is not supported --aws-zone-tags==tag-value # this is not supported ``` ## Filtering Workflows ***Filtering Sequence*** The diagram describes the sequence for filtering AWS zones. ```mermaid flowchart TD A["zones"] --> B{"Is zonesCache valid?"} B -- Yes --> C["Return cached zones"] B -- No --> D["Initialize zones map"] D --> E["For each profile and client"] E --> F["Create paginator"] F --> G{"Has more pages?"} G -- Yes --> H["Get next page"] H --> I["For each zone in page"] I --> J{"Match zoneIDFilter?"} J -- No --> G J -- Yes --> K{"Match zoneTypeFilter?"} K -- No --> G K -- Yes --> L{"Match domainFilter?"} L -- No --> M{"zoneMatchParent?"} M -- No --> G M -- Yes --> N{"Match domainFilter parent?"} N -- No --> G N -- Yes --> O{"zoneTagFilter specified?"} O -- Yes --> P["Add zone to zonesToValidate"] O -- No --> Q["Add zone to zones map"] P --> Q Q --> G G -- No --> R{"zonesToValidate not empty?"} R -- Yes --> S["Get tags for zones"] S --> T["For each zone and tags"] T --> U{"Match zoneTagFilter?"} U -- No --> V["Delete zone from zones map"] U -- Yes --> W["Keep zone in zones map"] V --> W W --> R R -- No --> X["Update zonesCache"] X --> Y["Return zones"] ``` ***Filtering Flow*** The is a sequence diagram that describes the interaction between `external-dns`, `AWSProvider`, and `Route53Client` during the filtering process. Here is a high-level description: ```mermaid sequenceDiagram participant external-dns participant AWSProvider participant Route53Client external-dns->>AWSProvider: zones alt Cache is valid AWSProvider-->>external-dns: return cached zones else AWSProvider->>Route53Client: ListHostedZonesPaginator loop While paginator.HasMorePages Route53Client->>AWSProvider: paginator.NextPage alt ThrottlingException AWSProvider->>external-dns: error else AWSProvider-->>external-dns: return error end AWSProvider->>AWSProvider: Filter zones alt Tags need validation AWSProvider->>Route53Client: ListTagsForResources Route53Client->>AWSProvider: return tags AWSProvider->>AWSProvider: Validate tags end end alt Cache duration > 0 AWSProvider->>AWSProvider: Update cache end AWSProvider-->>external-dns: return zones end ``` ================================================ FILE: docs/tutorials/aws-load-balancer-controller.md ================================================ # AWS Load Balancer Controller This tutorial describes how to use ExternalDNS with the [aws-load-balancer-controller][1]. [1]: https://kubernetes-sigs.github.io/aws-load-balancer-controller ## Setting up ExternalDNS and aws-load-balancer-controller Follow the [AWS tutorial](aws.md) to setup ExternalDNS for use in Kubernetes clusters running in AWS. Specify the `source=ingress` argument so that ExternalDNS will look for hostnames in Ingress objects. In addition, you may wish to limit which Ingress objects are used as an ExternalDNS source via the `ingress-class` argument, but this is not required. For help setting up the AWS Load Balancer Controller, follow the [Setup Guide][2]. [2]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/deploy/installation/ Note that the AWS Load Balancer Controller uses the same tags for [subnet auto-discovery][3] as Kubernetes does with the AWS cloud provider. [3]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/deploy/subnet_discovery/ In the examples that follow, it is assumed that you configured the ALB Ingress Controller with the `ingress-class=alb` argument (not to be confused with the same argument to ExternalDNS) so that the controller will only respect Ingress objects with the `ingressClassName` field set to "alb". ## Deploy an example application Create the following sample "echoserver" application to demonstrate how ExternalDNS works with ALB ingress objects. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: echoserver spec: replicas: 1 selector: matchLabels: app: echoserver template: metadata: labels: app: echoserver spec: containers: - image: gcr.io/google_containers/echoserver:1.4 imagePullPolicy: Always name: echoserver ports: - containerPort: 8080 --- apiVersion: v1 kind: Service metadata: name: echoserver spec: ports: - port: 80 targetPort: 8080 protocol: TCP type: NodePort selector: app: echoserver ``` Note that the Service object is of type `NodePort`. We don't need a Service of type `LoadBalancer` here, since we will be using an Ingress to create an ALB. ## Ingress examples Create the following Ingress to expose the echoserver application to the Internet. ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: alb.ingress.kubernetes.io/scheme: internet-facing name: echoserver spec: ingressClassName: alb rules: - host: echoserver.mycluster.example.org http: &echoserver_root paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix - host: echoserver.example.org http: *echoserver_root ``` The above should result in the creation of an (ipv4) ALB in AWS which will forward traffic to the echoserver application. If the `--source=ingress` argument is specified, then ExternalDNS will create DNS records based on the hosts specified in ingress objects. The above example would result in two alias records (A and AAAA) being created for each of the domains: `echoserver.mycluster.example.org` and `echoserver.example.org`. All four records alias the ALB that is associated with the Ingress object. As the ALB is IPv4 only, the AAAA alias records have no effect. If you would like ExternalDNS to not create AAAA records at all, you can add the following command line parameter: `--exclude-record-types=AAAA`. Please be aware, this will disable AAAA record creation even for dualstack enabled load balancers. Note that the above example makes use of the YAML anchor feature to avoid having to repeat the http section for multiple hosts that use the exact same paths. If this Ingress object will only be fronting one backend Service, we might instead create the following: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: alb.ingress.kubernetes.io/scheme: internet-facing external-dns.alpha.kubernetes.io/hostname: echoserver.mycluster.example.org, echoserver.example.org name: echoserver spec: ingressClassName: alb rules: - http: paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix ``` In the above example we create a default path that works for any hostname, and make use of the `external-dns.alpha.kubernetes.io/hostname` annotation to create multiple aliases for the resulting ALB. ## Dualstack Load Balancers AWS [supports both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces for ALBs][4] and [NLBs][5]. The AWS Load Balancer Controller uses the `alb.ingress.kubernetes.io/ip-address-type` annotation (which defaults to `ipv4`) to determine this. ExternalDNS creates both A and AAAA alias DNS records by default, regardless of this annotation. It's possible to create only A records with the following command line parameter: `--exclude-record-types=AAAA` [4]: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#ip-address-type [5]: https://docs.aws.amazon.com/elasticloadbalancing/latest/network/network-load-balancers.html#ip-address-type Example: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/ip-address-type: dualstack name: echoserver spec: ingressClassName: alb rules: - host: echoserver.example.org http: paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix ``` The above Ingress object will result in the creation of an ALB with a dualstack interface. ## Frontend Network Load Balancer (NLB) The AWS Load Balancer Controller supports [fronting ALBs with an NLB][6] for improved performance and static IP addresses. When this feature is enabled, the controller creates both an ALB and an NLB, resulting in two hostnames in the Ingress status. [6]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/ingress/annotations/#enable-frontend-nlb ### Known Issue with Internal ALBs When using an internal ALB (`alb.ingress.kubernetes.io/scheme: internal`) with frontend NLB, ExternalDNS may create DNS records pointing to the ALB instead of the NLB due to alphabetical ordering: - Internal ALB hostname: `internal-k8s-myapp-alb.us-east-1.elb.amazonaws.com` - NLB hostname: `k8s-myapp-nlb-123456789.elb.us-east-1.amazonaws.com` When multiple targets exist, Route53 selects the first one alphabetically, which incorrectly selects the internal ALB. See [issue #5661][7] for details. [7]: https://github.com/kubernetes-sigs/external-dns/issues/5661 ### Workarounds There are several approaches to ensure DNS records point to the correct (NLB) target: #### Option 1: Combine load balancer naming with target annotation (Recommended) Use [`alb.ingress.kubernetes.io/load-balancer-name`][8] to create predictable hostnames, then explicitly reference the NLB using [`external-dns.alpha.kubernetes.io/target`][9]: [8]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/guide/ingress/annotations/#load-balancer-name [9]: https://kubernetes-sigs.github.io/external-dns/latest/docs/annotations/annotations/#external-dnsalphakubernetesiotarget ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: alb.ingress.kubernetes.io/scheme: internal alb.ingress.kubernetes.io/enable-frontend-nlb: "true" alb.ingress.kubernetes.io/frontend-nlb-scheme: internal alb.ingress.kubernetes.io/load-balancer-name: myapp-alb external-dns.alpha.kubernetes.io/target: k8s-myapp-nlb.elb.us-east-1.amazonaws.com name: echoserver spec: ingressClassName: alb rules: - host: echoserver.example.org http: paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix ``` **Benefits**: - Predictable, consistent load balancer naming across environments - Explicit control over which target ExternalDNS uses - Works reliably with internal ALBs - No need to lookup auto-generated NLB names **NLB hostname pattern**: When you set `load-balancer-name: myapp-alb`, the NLB hostname becomes `k8s-myapp-nlb.elb..amazonaws.com` (note the `-nlb` suffix). **ALB internal hostname pattern**: When you set `load-balancer-name: myapp-alb`, the ALB hostname becomes `internal-myapp-nlb..elb.amazonaws.com` (note the `internal-` suffix). #### Option 2: Use the target annotation only If you cannot control the load balancer name, explicitly specify the NLB hostname: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: alb.ingress.kubernetes.io/scheme: internal alb.ingress.kubernetes.io/enable-frontend-nlb: "true" alb.ingress.kubernetes.io/frontend-nlb-scheme: internal external-dns.alpha.kubernetes.io/target: k8s-myapp-nlb-123456789.elb.us-east-1.amazonaws.com name: echoserver spec: ingressClassName: alb rules: - host: echoserver.example.org http: paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix ``` **Note**: You'll need to lookup the auto-generated NLB hostname after the controller creates it. #### Option 3: Use a DNSEndpoint resource Create a [`DNSEndpoint`][10] custom resource to explicitly define the DNS record: ```yaml apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: echoserver-dns spec: endpoints: - dnsName: echoserver.example.org recordType: CNAME targets: - k8s-myapp-nlb-123456789.elb.us-east-1.amazonaws.com ``` This approach is useful when you want to manage DNS records independently of the Ingress resource. [10]:https://kubernetes-sigs.github.io/external-dns/latest/docs/tutorials/crd/ ================================================ FILE: docs/tutorials/aws-public-private-route53.md ================================================ # AWS Route53 with same domain for public and private zones This tutorial describes how to setup ExternalDNS using the same domain for public and private Route53 zones and [nginx-ingress-controller](https://github.com/kubernetes/ingress-nginx). It also outlines how to use [cert-manager](https://github.com/jetstack/cert-manager) to automatically issue SSL certificates from [Let's Encrypt](https://letsencrypt.org/) for both public and private records. ## Deploy public nginx-ingress-controller You may be interested with [GKE with nginx ingress](gke-nginx.md) for installation guidelines. Specify `ingress-class` in nginx-ingress-controller container args: ```yaml apiVersion: apps/v1 kind: Deployment metadata: labels: app: external-ingress name: external-ingress-controller spec: replicas: 1 selector: matchLabels: app: external-ingress template: metadata: labels: app: external-ingress spec: containers: - args: - /nginx-ingress-controller - --default-backend-service=$(POD_NAMESPACE)/default-http-backend - --configmap=$(POD_NAMESPACE)/external-ingress-configuration - --tcp-services-configmap=$(POD_NAMESPACE)/external-tcp-services - --udp-services-configmap=$(POD_NAMESPACE)/external-udp-services - --annotations-prefix=nginx.ingress.kubernetes.io - --ingress-class=external-ingress - --publish-service=$(POD_NAMESPACE)/external-ingress env: - name: POD_NAME valueFrom: fieldRef: apiVersion: v1 fieldPath: metadata.name - name: POD_NAMESPACE valueFrom: fieldRef: apiVersion: v1 fieldPath: metadata.namespace image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.11.0 livenessProbe: failureThreshold: 3 httpGet: path: /healthz port: 10254 scheme: HTTP initialDelaySeconds: 10 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1 name: external-ingress-controller ports: - containerPort: 80 name: http protocol: TCP - containerPort: 443 name: https protocol: TCP readinessProbe: failureThreshold: 3 httpGet: path: /healthz port: 10254 scheme: HTTP periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1 ``` Set `type: LoadBalancer` in your public nginx-ingress-controller Service definition. ```yaml apiVersion: v1 kind: Service metadata: annotations: service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: "3600" service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: '*' labels: app: external-ingress name: external-ingress spec: externalTrafficPolicy: Cluster ports: - name: http port: 80 protocol: TCP targetPort: http - name: https port: 443 protocol: TCP targetPort: https selector: app: external-ingress sessionAffinity: None type: LoadBalancer ``` ## Deploy private nginx-ingress-controller Make sure to specify `ingress-class` in nginx-ingress-controller container args: ```yaml apiVersion: apps/v1 kind: Deployment metadata: labels: app: internal-ingress name: internal-ingress-controller spec: replicas: 1 selector: matchLabels: app: internal-ingress template: metadata: labels: app: internal-ingress spec: containers: - args: - /nginx-ingress-controller - --default-backend-service=$(POD_NAMESPACE)/default-http-backend - --configmap=$(POD_NAMESPACE)/internal-ingress-configuration - --tcp-services-configmap=$(POD_NAMESPACE)/internal-tcp-services - --udp-services-configmap=$(POD_NAMESPACE)/internal-udp-services - --annotations-prefix=nginx.ingress.kubernetes.io - --ingress-class=internal-ingress - --publish-service=$(POD_NAMESPACE)/internal-ingress env: - name: POD_NAME valueFrom: fieldRef: apiVersion: v1 fieldPath: metadata.name - name: POD_NAMESPACE valueFrom: fieldRef: apiVersion: v1 fieldPath: metadata.namespace image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.11.0 livenessProbe: failureThreshold: 3 httpGet: path: /healthz port: 10254 scheme: HTTP initialDelaySeconds: 10 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1 name: internal-ingress-controller ports: - containerPort: 80 name: http protocol: TCP - containerPort: 443 name: https protocol: TCP readinessProbe: failureThreshold: 3 httpGet: path: /healthz port: 10254 scheme: HTTP periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1 ``` Set additional annotations in your private nginx-ingress-controller Service definition to create an internal load balancer. ```yaml apiVersion: v1 kind: Service metadata: annotations: service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout: "3600" service.beta.kubernetes.io/aws-load-balancer-internal: 0.0.0.0/0 service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: '*' labels: app: internal-ingress name: internal-ingress spec: externalTrafficPolicy: Cluster ports: - name: http port: 80 protocol: TCP targetPort: http - name: https port: 443 protocol: TCP targetPort: https selector: app: internal-ingress sessionAffinity: None type: LoadBalancer ``` ## Deploy the public zone ExternalDNS Consult [AWS ExternalDNS setup docs](aws.md) for installation guidelines. In ExternalDNS containers args, make sure to specify `aws-zone-type` and `ingress-class`: ```yaml apiVersion: apps/v1 kind: Deployment metadata: labels: app: external-dns-public name: external-dns-public namespace: kube-system spec: replicas: 1 selector: matchLabels: app: external-dns-public strategy: type: Recreate template: metadata: labels: app: external-dns-public spec: containers: - args: - --source=ingress - --provider=aws - --registry=txt - --txt-owner-id=external-dns - --ingress-class=external-ingress - --aws-zone-type=public image: registry.k8s.io/external-dns/external-dns:v0.20.0 name: external-dns-public ``` ## Deploy the private zone ExternalDNS Consult [AWS ExternalDNS setup docs](aws.md) for installation guidelines. In ExternalDNS containers args, make sure to specify `aws-zone-type` and `ingress-class`: ```yaml apiVersion: apps/v1 kind: Deployment metadata: labels: app: external-dns-private name: external-dns-private namespace: kube-system spec: replicas: 1 selector: matchLabels: app: external-dns-private strategy: type: Recreate template: metadata: labels: app: external-dns-private spec: containers: - args: - --source=ingress - --provider=aws - --registry=txt - --txt-owner-id=dev.k8s.nexus - --ingress-class=internal-ingress - --aws-zone-type=private image: registry.k8s.io/external-dns/external-dns:v0.20.0 name: external-dns-private ``` ## Create application Service definitions For this setup to work, you need to create two Ingress definitions for your application. At first, create a public Ingress definition: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: labels: app: app name: app-public spec: ingressClassName: external-ingress rules: - host: app.domain.com http: paths: - backend: service: name: app port: number: 80 pathType: Prefix ``` Then create a private Ingress definition: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: labels: app: app name: app-private spec: ingressClassName: internal-ingress rules: - host: app.domain.com http: paths: - backend: service: name: app port: number: 80 pathType: Prefix ``` Additionally, you may leverage [cert-manager](https://github.com/jetstack/cert-manager) to automatically issue SSL certificates from [Let's Encrypt](https://letsencrypt.org/). To do that, request a certificate in public service definition: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: certmanager.k8s.io/acme-challenge-type: "dns01" certmanager.k8s.io/acme-dns01-provider: "route53" certmanager.k8s.io/cluster-issuer: "letsencrypt-production" kubernetes.io/tls-acme: "true" labels: app: app name: app-public spec: ingressClassName: "external-ingress" rules: - host: app.domain.com http: paths: - backend: service: name: app port: number: 80 pathType: Prefix tls: - hosts: - app.domain.com secretName: app-tls ``` And reuse the requested certificate in private Service definition: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: labels: app: app name: app-private spec: ingressClassName: "internal-ingress" rules: - host: app.domain.com http: paths: - backend: service: name: app port: number: 80 pathType: Prefix tls: - hosts: - app.domain.com secretName: app-tls ``` ================================================ FILE: docs/tutorials/aws-sd.md ================================================ # AWS Cloud Map API This tutorial describes how to set up ExternalDNS for usage within a Kubernetes cluster with [AWS Cloud Map API](https://docs.aws.amazon.com/cloud-map/). **AWS Cloud Map** API is an alternative approach to managing DNS records directly using the Route53 API. It is more suitable for a dynamic environment where service endpoints change frequently. It abstracts away technical details of the DNS protocol and offers a simplified model. AWS Cloud Map consists of three main API calls: * CreatePublicDnsNamespace – automatically creates a DNS hosted zone * CreateService – creates a new named service inside the specified namespace * RegisterInstance/DeregisterInstance – can be called multiple times to create a DNS record for the specified *Service* Learn more about the API in the [AWS Cloud Map API Reference](https://docs.aws.amazon.com/cloud-map/latest/api/API_Operations.html). ## IAM Permissions To use the AWS Cloud Map API, a user must have permissions to create the DNS namespace. You need to make sure that your nodes (on which External DNS runs) have an IAM instance profile with the `AWSCloudMapFullAccess` managed policy attached, that provides following permissions: > Please be aware that this IAM role grants broad permissions across Route 53, and Service Discovery. For enhanced security, it's strongly recommended to review and restrict the actions and resources to the absolute minimum required for its intended purpose, following the principle of least privilege ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "route53:GetHostedZone", "route53:ListHostedZonesByName", "route53:CreateHostedZone", "route53:DeleteHostedZone", "route53:ChangeResourceRecordSets", "route53:CreateHealthCheck", "route53:GetHealthCheck", "route53:DeleteHealthCheck", "route53:UpdateHealthCheck", "ec2:DescribeVpcs", "ec2:DescribeRegions", "servicediscovery:*" ], "Resource": [ "*" ] } ] } ``` ### IAM Permissions with ABAC You can use Attribute-based access control(ABAC) for advanced deployments. You can define AWS tags that are applied to services created by the controller. By doing so, you can have precise control over your IAM policy to limit the scope of the permissions to services managed by the controller, rather than having to grant full permissions on your entire AWS account. To pass tags to service creation, use either CLI flags or environment variables: *cli:* `--aws-sd-create-tag=key1=value1 --aws-sd-create-tag=key2=value2` *environment:* `EXTERNAL_DNS_AWS_SD_CREATE_TAG=key1=value1\nkey2=value2` Using tags, your `servicediscovery` policy can become: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "route53:ChangeResourceRecordSets" ], "Resource": [ "arn:aws:route53:::hostedzone/*" ], "Condition": { "ForAllValues:StringLike": { "route53:ChangeResourceRecordSetsNormalizedRecordNames": ["*example.com", "marketing.example.com", "*-beta.example.com"], "route53:ChangeResourceRecordSetsActions": ["CREATE", "UPSERT", "DELETE"], "route53:ChangeResourceRecordSetsRecordTypes": ["A", "AAAA", "CNAME", "MX", "TXT"] } } }, { "Effect": "Allow", "Action": [ "servicediscovery:ListNamespaces", "servicediscovery:ListServices" ], "Resource": [ "*" ] }, { "Effect": "Allow", "Action": [ "servicediscovery:CreateService", "servicediscovery:TagResource" ], "Resource": [ "*" ], "Condition": { "StringEquals": { "aws:RequestTag/YOUR_TAG_KEY": "YOUR_TAG_VALUE" } } }, { "Effect": "Allow", "Action": [ "servicediscovery:DiscoverInstances" ], "Resource": [ "*" ], "Condition": { "StringEquals": { "servicediscovery:NamespaceName": "YOUR_NAMESPACE_NAME" } } }, { "Effect": "Allow", "Action": [ "servicediscovery:RegisterInstance", "servicediscovery:DeregisterInstance", "servicediscovery:DeleteService", "servicediscovery:UpdateService" ], "Resource": [ "*" ], "Condition": { "StringEquals": { "aws:ResourceTag/YOUR_TAG_KEY": "YOUR_TAG_VALUE" } } } ] } ``` Additional resources: * AWS IAM actions [documentation](https://www.awsiamactions.io/?o=servicediscovery%3A) ## Set up a namespace Create a DNS namespace using the AWS Cloud Map API: ```console aws servicediscovery create-public-dns-namespace --name "external-dns-test.my-org.com" ``` Verify that the namespace was truly created ```console aws servicediscovery list-namespaces ``` ## Deploy ExternalDNS Connect your `kubectl` client to the cluster that you want to test ExternalDNS with. Then apply the following manifest file to deploy ExternalDNS. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 env: - name: AWS_REGION value: us-east-1 # put your CloudMap NameSpace region args: - --source=service - --source=ingress - --domain-filter=external-dns-test.my-org.com # Makes ExternalDNS see only the namespaces that match the specified domain. Omit the filter if you want to process all available namespaces. - --provider=aws-sd - --aws-zone-type=public # Only look at public namespaces. Valid values are public, private, or no value for both) - --txt-owner-id=my-identifier ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list","watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 env: - name: AWS_REGION value: us-east-1 # put your CloudMap NameSpace region args: - --source=service - --source=ingress - --domain-filter=external-dns-test.my-org.com # Makes ExternalDNS see only the namespaces that match the specified domain. Omit the filter if you want to process all available namespaces. - --provider=aws-sd - --aws-zone-type=public # Only look at public namespaces. Valid values are public, private, or no value for both) - --txt-owner-id=my-identifier ``` ## Verify that ExternalDNS works (Service example) Create the following sample application to test that ExternalDNS works. > For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value. ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com spec: type: LoadBalancer ports: - port: 80 name: http targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 name: http ``` After one minute check that a corresponding DNS record for your service was created in your hosted zone. We recommended that you use the [Amazon Route53 console](https://console.aws.amazon.com/route53) for that purpose. ## Custom TTL The default DNS record TTL (time to live) is 300 seconds. You can customize this value by setting the annotation `external-dns.alpha.kubernetes.io/ttl`. For example, modify the service manifest YAML file above: ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com external-dns.alpha.kubernetes.io/ttl: "60" spec: ... ``` This will set the TTL for the DNS record to 60 seconds. ## IPv6 Support If your Kubernetes cluster is configured with IPv6 support, such as an [EKS cluster with IPv6 support](https://docs.aws.amazon.com/eks/latest/userguide/deploy-ipv6-cluster.html), ExternalDNS can also create AAAA DNS records. ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com external-dns.alpha.kubernetes.io/ttl: "60" spec: ipFamilies: - "IPv6" type: NodePort ports: - port: 80 name: http targetPort: 80 selector: app: nginx ``` :information_source: The AWS-SD provider does not currently support dualstack load balancers and will only create A records for these at this time. See the AWS provider and the [AWS Load Balancer Controller Tutorial](./aws-load-balancer-controller.md) for dualstack load balancer support. ## Clean up Delete all service objects before terminating the cluster so all load balancers get cleaned up correctly. ```console kubectl delete service nginx ``` Give ExternalDNS some time to clean up the DNS records for you. Then delete the remaining service and namespace. ```console $ aws servicediscovery list-services { "Services": [ { "Id": "srv-6dygt5ywvyzvi3an", "Arn": "arn:aws:servicediscovery:us-west-2:861574988794:service/srv-6dygt5ywvyzvi3an", "Name": "nginx" } ] } ``` ```console aws servicediscovery delete-service --id srv-6dygt5ywvyzvi3an ``` ```console $ aws servicediscovery list-namespaces { "Namespaces": [ { "Type": "DNS_PUBLIC", "Id": "ns-durf2oxu4gxcgo6z", "Arn": "arn:aws:servicediscovery:us-west-2:861574988794:namespace/ns-durf2oxu4gxcgo6z", "Name": "external-dns-test.my-org.com" } ] } ``` ```console aws servicediscovery delete-namespace --id ns-durf2oxu4gxcgo6z ``` ================================================ FILE: docs/tutorials/aws.md ================================================ # AWS This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster on AWS. Make sure to use **>=0.15.0** version of ExternalDNS for this tutorial ## IAM Policy The following IAM Policy document allows ExternalDNS to update Route53 Resource Record Sets and Hosted Zones. You'll want to create this Policy in IAM first. In our example, we'll call the policy `AllowExternalDNSUpdates` (but you can call it whatever you prefer). ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "route53:ChangeResourceRecordSets", "route53:ListResourceRecordSets", "route53:ListTagsForResources" ], "Resource": [ "arn:aws:route53:::hostedzone/*" ] }, { "Effect": "Allow", "Action": [ "route53:ListHostedZones" ], "Resource": [ "*" ] } ] } ``` ### IAM Permissions with ABAC You can use Attribute-based access control(ABAC) for advanced deployments. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "route53:ChangeResourceRecordSets", "route53:ListResourceRecordSets", "route53:ListTagsForResources" ], "Resource": [ "arn:aws:route53:::hostedzone/*" ], "Condition": { "ForAllValues:StringLike": { "route53:ChangeResourceRecordSetsNormalizedRecordNames": ["*example.com", "marketing.example.com", "*-beta.example.com"], "route53:ChangeResourceRecordSetsActions": ["CREATE", "UPSERT", "DELETE"], "route53:ChangeResourceRecordSetsRecordTypes": ["A", "AAAA", "CNAME", "MX", "TXT"] } } }, { "Effect": "Allow", "Action": [ "route53:ListHostedZones" ], "Resource": [ "*" ] } ] } ``` ### Further improvements Both policies can be further enhanced by tightening them down following the [principle of least privilege](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege). Explicitly providing a list of selected zones instead of `*` you can scope the deployment down allowing changes only to zones from the list hence reducing the blast radius and improving auditability. Additional resources: - AWS IAM actions [documentation](https://www.awsiamactions.io/?o=route53%3A) - AWS IAM [fine grained controll](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/specifying-conditions-route53.html#route53_rrsetConditionKeys) - [Actions and condition keys for Amazon Route 53](https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonroute53.html) ## Create Role with AWS CLI If you are using the AWS CLI, you can run the following to install the above policy (saved as `policy.json`). This can be use in subsequent steps to allow ExternalDNS to access Route53 zones. ```bash aws iam create-policy --policy-name "AllowExternalDNSUpdates" --policy-document file://policy.json # example: arn:aws:iam::XXXXXXXXXXXX:policy/AllowExternalDNSUpdates export POLICY_ARN=$(aws iam list-policies \ --query 'Policies[?PolicyName==`AllowExternalDNSUpdates`].Arn' --output text) ``` ## Provisioning a Kubernetes cluster You can use [eksctl](https://eksctl.io) to easily provision an [Amazon Elastic Kubernetes Service](https://aws.amazon.com/eks) ([EKS](https://aws.amazon.com/eks)) cluster that is suitable for this tutorial. See [Getting started with Amazon EKS – eksctl](https://docs.aws.amazon.com/eks/latest/userguide/getting-started-eksctl.html). ```bash export EKS_CLUSTER_NAME="my-externaldns-cluster" export EKS_CLUSTER_REGION="us-east-2" export KUBECONFIG="$HOME/.kube/${EKS_CLUSTER_NAME}-${EKS_CLUSTER_REGION}.yaml" eksctl create cluster --name $EKS_CLUSTER_NAME --region $EKS_CLUSTER_REGION ``` Feel free to use other provisioning tools or an existing cluster. If [Terraform](https://www.terraform.io/) is used, [vpc](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/) and [eks](https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/) modules are recommended for standing up an EKS cluster. Amazon has a workshop called [Amazon EKS Terraform Workshop](https://catalog.us-east-1.prod.workshops.aws/workshops/afee4679-89af-408b-8108-44f5b1065cc7/) that may be useful for this process. ## Permissions to modify DNS zone You will need to use the above policy (represented by the `POLICY_ARN` environment variable) to allow ExternalDNS to update records in Route53 DNS zones. Here are three common ways this can be accomplished: - [Node IAM Role](#node-iam-role) - [Static credentials](#static-credentials) - [IAM Roles for Service Accounts](#iam-roles-for-service-accounts) For this tutorial, ExternalDNS will use the environment variable `EXTERNALDNS_NS` to represent the namespace, defaulted to `default`. Feel free to change this to something else, such `externaldns` or `kube-addons`. Make sure to edit the `subjects[0].namespace` for the `ClusterRoleBinding` resource when deploying ExternalDNS with RBAC enabled. See [When using clusters with RBAC enabled](#when-using-clusters-with-rbac-enabled) for more information. Additionally, throughout this tutorial, the example domain of `example.com` is used. Change this to appropriate domain under your control. See [Set up a hosted zone](#set-up-a-hosted-zone) section. ### Node IAM Role In this method, you can attach a policy to the Node IAM Role. This will allow nodes in the Kubernetes cluster to access Route53 zones, which allows ExternalDNS to update DNS records. Given that this allows all containers to access Route53, not just ExternalDNS, running on the node with these privileges, this method is not recommended, and is only suitable for limited test environments. If you are using eksctl to provision a new cluster, you add the policy at creation time with: ```bash eksctl create cluster --external-dns-access \ --name $EKS_CLUSTER_NAME --region $EKS_CLUSTER_REGION \ ``` :warning: **WARNING**: This will assign allow read-write access to all nodes in the cluster, not just ExternalDNS. For this reason, this method is only suitable for limited test environments. If you already provisioned a cluster or use other provisioning tools like Terraform, you can use AWS CLI to attach the policy to the Node IAM Role. #### Get the Node IAM role name The role name of the role associated with the node(s) where ExternalDNS will run is needed. An easy way to get the role name is to use the AWS web console (https://console.aws.amazon.com/eks/), and find any instance in the target node group and copy the role name associated with that instance. ##### Get role name with a single managed nodegroup From the command line, if you have a single managed node group, the default with `eksctl create cluster`, you can find the role name with the following: ```bash # get managed node group name (assuming there's only one node group) GROUP_NAME=$(aws eks list-nodegroups --cluster-name $EKS_CLUSTER_NAME \ --query nodegroups --out text) # fetch role arn given node group name ROLE_ARN=$(aws eks describe-nodegroup --cluster-name $EKS_CLUSTER_NAME \ --nodegroup-name $GROUP_NAME --query nodegroup.nodeRole --out text) # extract just the name part of role arn ROLE_NAME=${NODE_ROLE_ARN##*/} ``` ##### Get role name with other configurations If you have multiple node groups or any unmanaged node groups, the process gets more complex. The first step is to get the instance host name of the desired node to where ExternalDNS will be deployed or is already deployed: ```bash # node instance name of one of the external dns pods currently running INSTANCE_NAME=$(kubectl get pods --all-namespaces \ --selector app.kubernetes.io/instance=external-dns \ --output jsonpath='{.items[0].spec.nodeName}') # instance name of one of the nodes (change if node group is different) INSTANCE_NAME=$(kubectl get nodes --output name | cut -d'/' -f2 | tail -1) ``` With the instance host name, you can then get the instance id: ```bash get_instance_id() { INSTANCE_NAME=$1 # example: ip-192-168-74-34.us-east-2.compute.internal # get list of nodes # ip-192-168-74-34.us-east-2.compute.internal aws:///us-east-2a/i-xxxxxxxxxxxxxxxxx # ip-192-168-86-105.us-east-2.compute.internal aws:///us-east-2a/i-xxxxxxxxxxxxxxxxx NODES=$(kubectl get nodes \ --output jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.providerID}{"\n"}{end}') # print instance id from matching node grep $INSTANCE_NAME <<< "$NODES" | cut -d'/' -f5 } INSTANCE_ID=$(get_instance_id $INSTANCE_NAME) ``` With the instance id, you can get the associated role name: ```bash findRoleName() { INSTANCE_ID=$1 # get all of the roles ROLES=($(aws iam list-roles --query Roles[*].RoleName --out text)) for ROLE in ${ROLES[*]}; do # get instance profile arn PROFILE_ARN=$(aws iam list-instance-profiles-for-role \ --role-name $ROLE --query InstanceProfiles[0].Arn --output text) # if there is an instance profile if [[ "$PROFILE_ARN" != "None" ]]; then # get all the instances with this associated instance profile INSTANCES=$(aws ec2 describe-instances \ --filters Name=iam-instance-profile.arn,Values=$PROFILE_ARN \ --query Reservations[*].Instances[0].InstanceId --out text) # find instances that match the instant profile for INSTANCE in ${INSTANCES[*]}; do # set role name value if there is a match if [[ "$INSTANCE_ID" == "$INSTANCE" ]]; then ROLE_NAME=$ROLE; fi done fi done echo $ROLE_NAME } NODE_ROLE_NAME=$(findRoleName $INSTANCE_ID) ``` Using the role name, you can associate the policy that was created earlier: ```bash # attach policy arn created earlier to node IAM role aws iam attach-role-policy --role-name $NODE_ROLE_NAME --policy-arn $POLICY_ARN ``` :warning: **WARNING**: This will assign allow read-write access to all pods running on the same node pool, not just the ExternalDNS pod(s). #### Deploy ExternalDNS with attached policy to Node IAM Role If ExternalDNS is not yet deployed, follow the steps under [Deploy ExternalDNS](#deploy-externaldns) using either RBAC or non-RBAC. **NOTE**: Before deleting the cluster during, be sure to run `aws iam detach-role-policy`. Otherwise, there can be errors as the provisioning system, such as `eksctl` or `terraform`, will not be able to delete the roles with the attached policy. ### Static credentials In this method, the policy is attached to an IAM user, and the credentials secrets for the IAM user are then made available using a Kubernetes secret. This method is not the preferred method as the secrets in the credential file could be copied and used by an unauthorized threat actor. However, if the Kubernetes cluster is not hosted on AWS, it may be the only method available. Given this situation, it is important to limit the associated privileges to just minimal required privileges, i.e. read-write access to Route53, and not used a credentials file that has extra privileges beyond what is required. #### Create IAM user and attach the policy ```bash # create IAM user aws iam create-user --user-name "externaldns" # attach policy arn created earlier to IAM user aws iam attach-user-policy --user-name "externaldns" --policy-arn $POLICY_ARN ``` #### Create the static credentials ```bash SECRET_ACCESS_KEY=$(aws iam create-access-key --user-name "externaldns") ACCESS_KEY_ID=$(echo $SECRET_ACCESS_KEY | jq -r '.AccessKey.AccessKeyId') cat <<-EOF > credentials [default] aws_access_key_id = $(echo $ACCESS_KEY_ID) aws_secret_access_key = $(echo $SECRET_ACCESS_KEY | jq -r '.AccessKey.SecretAccessKey') EOF ``` #### Create Kubernetes secret from credentials ```bash kubectl create secret generic external-dns \ --namespace ${EXTERNALDNS_NS:-"default"} --from-file /local/path/to/credentials ``` #### Deploy ExternalDNS using static credentials Follow the steps under [Deploy ExternalDNS](#deploy-externaldns) using either RBAC or non-RBAC. Make sure to uncomment the section that mounts volumes, so that the credentials can be mounted. > [!TIP] > By default ExternalDNS takes the profile named `default` from the credentials file. If you want to use a different > profile, you can set the environment variable `EXTERNAL_DNS_AWS_PROFILE` to the desired profile name or use the > `--aws-profile` command line argument. It is even possible to use more than one profile at ones, separated by space in > the environment variable `EXTERNAL_DNS_AWS_PROFILE` or by using `--aws-profile` multiple times. In this case > ExternalDNS looks for the hosted zones in all profiles and keeps maintaining a mapping table between zone and profile > in order to be able to modify the zones in the correct profile. ### IAM Roles for Service Accounts [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) ([IAM roles for Service Accounts](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)) allows cluster operators to map AWS IAM Roles to Kubernetes Service Accounts. This essentially allows only ExternalDNS pods to access Route53 without exposing any static credentials. This is the preferred method as it implements [PoLP](https://csrc.nist.gov/glossary/term/principle_of_least_privilege) ([Principle of Least Privilege](https://csrc.nist.gov/glossary/term/principle_of_least_privilege)). > [!IMPORTANT] > This method requires using KSA (Kubernetes service account) and RBAC. This method requires deploying with RBAC. See [When using clusters with RBAC enabled](#when-using-clusters-with-rbac-enabled) when ready to deploy ExternalDNS. > [!NOTE] > Similar methods to IRSA on AWS are [kiam](https://github.com/uswitch/kiam), which is in maintenence mode, and has [instructions](https://github.com/uswitch/kiam/blob/HEAD/docs/IAM.md) for creating an IAM role, and also [kube2iam](https://github.com/jtblin/kube2iam). > IRSA is the officially supported method for EKS clusters, and so for non-EKS clusters on AWS, these other tools could be an option. #### Verify OIDC is supported ```bash aws eks describe-cluster --name $EKS_CLUSTER_NAME \ --query "cluster.identity.oidc.issuer" --output text ``` #### Associate OIDC to cluster Configure the cluster with an OIDC provider and add support for [IRSA](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) ([IAM roles for Service Accounts](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html)). If you used `eksctl` to provision the EKS cluster, you can update it with the following command: ```bash eksctl utils associate-iam-oidc-provider \ --cluster $EKS_CLUSTER_NAME --approve ``` If the cluster was provisioned with Terraform, you can use the `iam_openid_connect_provider` resource ([ref](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider)) to associate to the OIDC provider. #### Create an IAM role bound to a service account For the next steps in this process, we will need to associate the `external-dns` service account and a role used to grant access to Route53. This requires the following steps: 1. Create a role with a trust relationship to the cluster's OIDC provider 2. Attach the `AllowExternalDNSUpdates` policy to the role 3. Create the `external-dns` service account 4. Add annotation to the service account with the role arn ##### Use eksctl with eksctl created EKS cluster If `eksctl` was used to provision the EKS cluster, you can perform all of these steps with the following command: ```bash eksctl create iamserviceaccount \ --cluster $EKS_CLUSTER_NAME \ --name "external-dns" \ --namespace ${EXTERNALDNS_NS:-"default"} \ --attach-policy-arn $POLICY_ARN \ --approve ``` ##### Use aws cli with any EKS cluster Otherwise, we can do the following steps using `aws` commands (also see [Creating an IAM role and policy for your service account](https://docs.aws.amazon.com/eks/latest/userguide/create-service-account-iam-policy-and-role.html)): ```bash ACCOUNT_ID=$(aws sts get-caller-identity \ --query "Account" --output text) OIDC_PROVIDER=$(aws eks describe-cluster --name $EKS_CLUSTER_NAME \ --query "cluster.identity.oidc.issuer" --output text | sed -e 's|^https://||') cat <<-EOF > trust.json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::$ACCOUNT_ID:oidc-provider/$OIDC_PROVIDER" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "$OIDC_PROVIDER:sub": "system:serviceaccount:${EXTERNALDNS_NS:-"default"}:external-dns", "$OIDC_PROVIDER:aud": "sts.amazonaws.com" } } } ] } EOF IRSA_ROLE="external-dns-irsa-role" aws iam create-role --role-name $IRSA_ROLE --assume-role-policy-document file://trust.json aws iam attach-role-policy --role-name $IRSA_ROLE --policy-arn $POLICY_ARN ROLE_ARN=$(aws iam get-role --role-name $IRSA_ROLE --query Role.Arn --output text) # Create service account (skip if already created) kubectl create serviceaccount "external-dns" --namespace ${EXTERNALDNS_NS:-"default"} # Add annotation referencing IRSA role kubectl patch serviceaccount "external-dns" --namespace ${EXTERNALDNS_NS:-"default"} --patch \ "{\"metadata\": { \"annotations\": { \"eks.amazonaws.com/role-arn\": \"$ROLE_ARN\" }}}" ``` If any part of this step is misconfigured, such as the role with incorrect namespace configured in the trust relationship, annotation pointing the the wrong role, etc., you will see errors like `WebIdentityErr: failed to retrieve credentials`. Check the configuration and make corrections. When the service account annotations are updated, then the current running pods will have to be terminated, so that new pod(s) with proper configuration (environment variables) will be created automatically. When annotation is added to service account, the ExternalDNS pod(s) scheduled will have `AWS_ROLE_ARN`, `AWS_STS_REGIONAL_ENDPOINTS`, and `AWS_WEB_IDENTITY_TOKEN_FILE` environment variables injected automatically. #### Deploy ExternalDNS using IRSA Follow the steps under [When using clusters with RBAC enabled](#when-using-clusters-with-rbac-enabled). Make sure to comment out the service account section if this has been created already. If you deployed ExternalDNS before adding the service account annotation and the corresponding role, you will likely see error with `failed to list hosted zones: AccessDenied: User`. You can delete the current running ExternalDNS pod(s) after updating the annotation, so that new pods scheduled will have appropriate configuration to access Route53. ### EKS Pod Identity Associations Alternatively to [IRSA](#iam-roles-for-service-accounts) on AWS EKS it is possible to use the new native method `EKS Pod Identity`, which associates IAM roles with Kubernetes service accounts, simplifying the process of granting AWS permissions to any Pod. > [!IMPORTANT] > Differently from `IRSA`, this method is only available on AWS EKS clusters. > This feature also eliminates the need for third-party solutions such as [kiam](https://github.com/uswitch/kiam) or [kube2iam](https://github.com/jtblin/kube2iam). #### Check Pod Identity Agent is enabled This method requires the `Pod Identity Agent` installed on the cluster, hence the AWS EKS add-on `eks-pod-identity-agent`. Pod identity associations is running an agent as a daemonset on the worker nodes. It is also possible to create the add-on using `eksctl` ```bash eksctl create addon --cluster $EKS_CLUSTER_NAME --name eks-pod-identity-agent ``` #### Create an IAM role bound to a service account ##### Use eksctl with eksctl created EKS cluster If `eksctl` was used to provision the EKS cluster, you can perform all of these steps with the following command: ```bash eksctl create podidentityassociation \ --cluster $EKS_CLUSTER_NAME \ --namespace ${EXTERNALDNS_NS:-"default"} \ --service-account-name external-dns \ --role-name external-dns-pod-identity-role \ --permission-policy-arns $POLICY_ARN \ --approve ``` ##### Use aws cli with any EKS cluster ```bash cat <<-EOF > assume_role.json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "pods.eks.amazonaws.com" }, "Action": [ "sts:AssumeRole", "sts:TagSession" ] } ] } EOF POD_IDENTITY_ROLE="external-dns-pod-identity-role" aws iam create-role --role-name $POD_IDENTITY_ROLE --assume-role-policy-document file://assume_role.json aws iam attach-role-policy --role-name $POD_IDENTITY_ROLE --policy-arn $POLICY_ARN ROLE_ARN=$(aws iam get-role --role-name $POD_IDENTITY_ROLE --query Role.Arn --output text) # Create service account (skip if already created) kubectl create serviceaccount "external-dns" --namespace ${EXTERNALDNS_NS:-"default"} # Create Pod Identity association aws eks create-pod-identity-association \ --cluster-name $EKS_CLUSTER_NAME \ --namespace ${EXTERNALDNS_NS:-"default"} \ --service-account external-dns \ --role-arn $ROLE_ARN ``` ##### Use Terraform The same behaviour above can be achieved using Terraform. Here is a minimal placeholder snippet: ```hcl data "aws_iam_policy_document" "eks_assume_role" { statement { effect = "Allow" principals { type = "Service" identifiers = ["pods.eks.amazonaws.com"] } actions = [ "sts:AssumeRole", "sts:TagSession" ] } } resource "aws_iam_role" "external_dns_pod_identity" { name = "external-dns-pod-identity" assume_role_policy = data.aws_iam_policy_document.eks_assume_role.json } resource "aws_iam_role_policy_attachment" "external_dns_route53" { role = aws_iam_role.external_dns_pod_identity.name policy_arn = aws_iam_policy.external_dns_access.arn } # EKS Pod Identity for External DNS operator # connects Service Account and EDO Namespace (even if not created yet) resource "aws_eks_pod_identity_association" "external_dns_pod_identity" { cluster_name = aws_eks_cluster.eks.name namespace = "external-dns" service_account = "external-dns" role_arn = aws_iam_role.external_dns_pod_identity.arn } ``` #### Deploy ExternalDNS using Pod Identity Unlike the IRSA method, Pod Identity requires no further steps, nor service account annotations, since the pod identity association will bind the service account to the given IAM role, hence to a policy holding the requested set of permissions. The EKS Pod Identity Agent handles credential injection at runtime. ## Set up a hosted zone *If you prefer to try-out ExternalDNS in one of the existing hosted-zones you can skip this step* Create a DNS zone which will contain the managed DNS records. This tutorial will use the fictional domain of `example.com`. ```bash aws route53 create-hosted-zone --name "example.com." \ --caller-reference "external-dns-test-$(date +%s)" ``` Make a note of the nameservers that were assigned to your new zone. ```bash ZONE_ID=$(aws route53 list-hosted-zones-by-name --output json \ --dns-name "example.com." --query HostedZones[0].Id --out text) aws route53 list-resource-record-sets --output text \ --hosted-zone-id $ZONE_ID --query \ "ResourceRecordSets[?Type == 'NS'].ResourceRecords[*].Value | []" | tr '\t' '\n' ``` This should yield something similar this: ```sh ns-695.awsdns-22.net. ns-1313.awsdns-36.org. ns-350.awsdns-43.com. ns-1805.awsdns-33.co.uk. ``` If using your own domain that was registered with a third-party domain registrar, you should point your domain's name servers to the values in the from the list above. Please consult your registrar's documentation on how to do that. ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. You can check if your cluster has RBAC by `kubectl api-versions | grep rbac.authorization.k8s.io`. For clusters with RBAC enabled, be sure to choose the correct `namespace`. For this tutorial, the enviornment variable `EXTERNALDNS_NS` will refer to the namespace. You can set this to a value of your choice: ```bash export EXTERNALDNS_NS="default" # externaldns, kube-addons, etc # create namespace if it does not yet exist kubectl get namespaces | grep -q $EXTERNALDNS_NS || \ kubectl create namespace $EXTERNALDNS_NS ``` ## Using Helm (with OIDC) Create a values.yaml file to configure ExternalDNS: ```shell provider: name: aws env: - name: AWS_DEFAULT_REGION value: us-east-1 # change to region where EKS is installed ``` Finally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file: ```shell helm repo add --force-update external-dns https://kubernetes-sigs.github.io/external-dns/ helm upgrade --install external-dns external-dns/external-dns --values values.yaml ``` ### When using clusters without RBAC enabled Save the following below as `externaldns-no-rbac.yaml`. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns labels: app.kubernetes.io/name: external-dns spec: strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: external-dns template: metadata: labels: app.kubernetes.io/name: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-hostedzone-identifier env: - name: AWS_DEFAULT_REGION value: us-east-1 # change to region where EKS is installed # # Uncomment below if using static credentials # - name: AWS_SHARED_CREDENTIALS_FILE # value: /.aws/credentials # volumeMounts: # - name: aws-credentials # mountPath: /.aws # readOnly: true # volumes: # - name: aws-credentials # secret: # secretName: external-dns ``` When ready you can deploy: ```bash kubectl create --filename externaldns-no-rbac.yaml \ --namespace ${EXTERNALDNS_NS:-"default"} ``` ### When using clusters with RBAC enabled If you're using EKS, you can update the `values.yaml` file you created earlier to include the annotations to link the Role ARN you created before. ```yaml provider: name: aws serviceAccount: annotations: eks.amazonaws.com/role-arn: arn:aws:iam::${ACCOUNT_ID}:role/${EXTERNALDNS_ROLE_NAME:-"external-dns"} ``` If you need to provide credentials directly using a secret (ie. You're not using EKS), you can change the `values.yaml` file to include volume and volume mounts. ```yaml provider: name: aws env: - name: AWS_SHARED_CREDENTIALS_FILE value: /etc/aws/credentials/my_credentials extraVolumes: - name: aws-credentials secret: secretName: external-dns # In this example, the secret will have the data stored in a key named `my_credentials` extraVolumeMounts: - name: aws-credentials mountPath: /etc/aws/credentials readOnly: true ``` When ready, update your Helm installation: ```shell helm upgrade --install external-dns external-dns/external-dns --values values.yaml ``` ## Arguments This list is not the full list, but a few arguments that where chosen. ### aws-zone-type `aws-zone-type` allows filtering for private and public zones ## Annotations Annotations which are specific to AWS. ### alias `external-dns.alpha.kubernetes.io/alias` if set to `true` on an ingress, it will create two ALIAS records (one 'A' for IPv4 and one 'AAAA' for IPv6) when the target is an ALIAS as well. To make the target an alias, the ingress needs to be configured correctly as described in [the docs](./gke-nginx.md#with-a-separate-tcp-load-balancer). In particular, the argument `--publish-service=default/nginx-ingress-controller` has to be set on the `nginx-ingress-controller` container. If one uses the `nginx-ingress` Helm chart, this flag can be set with the `controller.publishService.enabled` configuration option. ### target-hosted-zone `external-dns.alpha.kubernetes.io/aws-target-hosted-zone` can optionally be set to the ID of a Route53 hosted zone. This will force external-dns to use the specified hosted zone when creating an ALIAS target. ### aws-zone-match-parent `aws-zone-match-parent` allows support subdomains within the same zone by using their parent domain, i.e --domain-filter=x.example.com would create a DNS entry for x.example.com (and subdomains thereof). ```yaml ## hosted zone domain: example.com --domain-filter=x.example.com,example.com --aws-zone-match-parent ``` ## Verify ExternalDNS works (Service example) Create the following sample application to test that ExternalDNS works. > For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value. > If you want to give multiple names to service, you can set it to external-dns.alpha.kubernetes.io/hostname with a comma `,` separator. For this verification phase, you can use default or another namespace for the nginx demo, for example: ```bash NGINXDEMO_NS="nginx" kubectl get namespaces | grep -q $NGINXDEMO_NS || kubectl create namespace $NGINXDEMO_NS ``` Save the following manifest below as `nginx.yaml`: ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.example.com spec: type: LoadBalancer ports: - port: 80 name: http targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 name: http ``` Deploy the nginx deployment and service with: ```bash kubectl create --filename nginx.yaml --namespace ${NGINXDEMO_NS:-"default"} ``` Verify that the load balancer was allocated with: ```bash kubectl get service nginx --namespace ${NGINXDEMO_NS:-"default"} ``` This should show something like: ```bash NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE nginx LoadBalancer 10.100.47.41 ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com. 80:32749/TCP 12m ``` After roughly two minutes check that a corresponding DNS record for your service that was created. ```bash aws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \ --query "ResourceRecordSets[?Name == 'nginx.example.com.']|[?Type == 'A']" ``` This should show something like: ```json [ { "Name": "nginx.example.com.", "Type": "A", "AliasTarget": { "HostedZoneId": "ZEWFWZ4R16P7IB", "DNSName": "ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.", "EvaluateTargetHealth": true } } ] ``` Or for IPv6 (AAAA) records: ```bash aws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \ --query "ResourceRecordSets[?Name == 'nginx.example.com.']|[?Type == 'AAAA']" ``` This should show something like: ```json [ { "Name": "nginx.example.com.", "Type": "AAAA", "AliasTarget": { "HostedZoneId": "ZEWFWZ4R16P7IB", "DNSName": "ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.", "EvaluateTargetHealth": true } } ] ``` IPv6 (AAAA) records are created when ALIAS is enabled even for load balancers that do not have dualstack enabled. However, Route53 returns empty sets when querying such records, meaning they are harmless and IPv4 will work as normal. You can also fetch the corresponding text records: ```bash aws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \ --query "ResourceRecordSets[?Name == 'nginx.example.com.']|[?Type == 'TXT']" ``` This will show something like: ```json [ { "Name": "nginx.example.com.", "Type": "TXT", "TTL": 300, "ResourceRecords": [ { "Value": "\"heritage=external-dns,external-dns/owner=external-dns,external-dns/resource=service/default/nginx\"" } ] } ] ``` Note created TXT record alongside ALIAS records. TXT record signifies that the corresponding ALIAS records are managed by ExternalDNS. This makes ExternalDNS safe for running in environments where there are other records managed via other means. For more information about ALIAS records, see [Choosing between alias and non-alias records](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html). Let's check that we can resolve this DNS name. We'll ask the nameservers assigned to your zone first. ```bash dig +short @ns-5514.awsdns-53.org. nginx.example.com. ``` This should return 1+ IP addresses that correspond to the ELB FQDN, i.e. `ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com.`. Next try the public nameservers configured by DNS client on your system: ```bash dig +short nginx.example.com. ``` If you hooked up your DNS zone with its parent zone correctly you can use `curl` to access your site. ```bash curl nginx.example.com. ``` This should show something like: ```html Welcome to nginx! ...

Welcome to nginx!

... ``` ## Verify ExternalDNS works (Ingress example) With the previous `deployment` and `service` objects deployed, we can add an `ingress` object and configure a FQDN value for the `host` key. The ingress controller will match incoming HTTP traffic, and route it to the appropriate backend service based on the `host` key. > For ingress objects ExternalDNS will create a DNS record based on the host specified for the ingress object. For this tutorial, we have two endpoints, the service with `LoadBalancer` type and an ingress. For practical purposes, if an ingress is used, the service type can be changed to `ClusterIP` as two endpoints are unecessary in this scenario. > [!IMPORTANT] > This requires that an ingress controller has been installed in your Kubernetes cluster. > EKS does not come with an ingress controller by default. A popular ingress controller is [ingress-nginx](https://github.com/kubernetes/ingress-nginx/), which can be installed by a [helm chart](https://artifacthub.io/packages/helm/ingress-nginx/ingress-nginx) or by [manifests](https://kubernetes.github.io/ingress-nginx/deploy/#aws). Create an ingress resource manifest file named `ingress.yaml` with the contents below: ```yaml --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx spec: ingressClassName: nginx rules: - host: server.example.com http: paths: - backend: service: name: nginx port: number: 80 path: / pathType: Prefix ``` When ready, you can deploy this with: ```bash kubectl create --filename ingress.yaml --namespace ${NGINXDEMO_NS:-"default"} ``` Watch the status of the ingress until the ADDRESS field is populated. ```bash kubectl get ingress --watch --namespace ${NGINXDEMO_NS:-"default"} ``` You should see something like this: ```sh NAME CLASS HOSTS ADDRESS PORTS AGE nginx server.example.com 80 47s nginx server.example.com ae11c2360188411e7951602725593fd1-1224345803.eu-central-1.elb.amazonaws.com. 80 54s ``` For the ingress test, run through similar checks, but using domain name used for the ingress: ```bash # check records on route53 aws route53 list-resource-record-sets --output json --hosted-zone-id $ZONE_ID \ --query "ResourceRecordSets[?Name == 'server.example.com.']" # query using a route53 name server dig +short @ns-5514.awsdns-53.org. server.example.com. # query using the default name server dig +short server.example.com. # connect to the nginx web server through the ingress curl server.example.com. ``` ## More service annotation options ### Custom TTL The default DNS record TTL (Time-To-Live) is 300 seconds. You can customize this value by setting the annotation `external-dns.alpha.kubernetes.io/ttl`. e.g., modify the service manifest YAML file above: ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.example.com external-dns.alpha.kubernetes.io/ttl: "60" spec: ... ``` This will set the DNS record's TTL to 60 seconds. ### Routing policies Route53 offers [different routing policies](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-policy.html). The routing policy for a record can be controlled with the following annotations: - `external-dns.alpha.kubernetes.io/set-identifier`: this **needs** to be set to use any of the following routing policies For any given DNS name, only **one** of the following routing policies can be used: - Weighted records: `external-dns.alpha.kubernetes.io/aws-weight` - Latency-based routing: `external-dns.alpha.kubernetes.io/aws-region` - Failover:`external-dns.alpha.kubernetes.io/aws-failover` - Geolocation-based routing: - `external-dns.alpha.kubernetes.io/aws-geolocation-continent-code` - `external-dns.alpha.kubernetes.io/aws-geolocation-country-code` - `external-dns.alpha.kubernetes.io/aws-geolocation-subdivision-code` - Geoproximity routing: - `external-dns.alpha.kubernetes.io/aws-geoproximity-region` - `external-dns.alpha.kubernetes.io/aws-geoproximity-local-zone-group` - `external-dns.alpha.kubernetes.io/aws-geoproximity-coordinates` - `external-dns.alpha.kubernetes.io/aws-geoproximity-bias` - Multi-value answer:`external-dns.alpha.kubernetes.io/aws-multi-value-answer` #### Weighted Routing Route traffic across two Services by weight. Both share the same hostname but carry different identifiers and weights: ```yaml apiVersion: v1 kind: Service metadata: name: my-service-v1 annotations: external-dns.alpha.kubernetes.io/hostname: app.example.com external-dns.alpha.kubernetes.io/set-identifier: app-v1 external-dns.alpha.kubernetes.io/aws-weight: "80" spec: type: LoadBalancer --- apiVersion: v1 kind: Service metadata: name: my-service-v2 annotations: external-dns.alpha.kubernetes.io/hostname: app.example.com external-dns.alpha.kubernetes.io/set-identifier: app-v2 external-dns.alpha.kubernetes.io/aws-weight: "20" spec: type: LoadBalancer ``` > ExternalDNS will create two Route53 weighted record sets for `app.example.com`, sending 80% of traffic to `my-service-v1` and 20% to `my-service-v2`. #### Failover Routing Designate a primary and secondary record for active/passive failover: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-ingress-primary annotations: external-dns.alpha.kubernetes.io/set-identifier: my-app-primary external-dns.alpha.kubernetes.io/aws-failover: PRIMARY --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-ingress-secondary annotations: external-dns.alpha.kubernetes.io/set-identifier: my-app-secondary external-dns.alpha.kubernetes.io/aws-failover: SECONDARY ``` > Route53 will serve the `PRIMARY` record when healthy, and automatically fall back to `SECONDARY` when the health check fails. #### Latency-Based Routing Route users to the nearest region by latency: ```yaml apiVersion: v1 kind: Service metadata: name: my-service-us annotations: external-dns.alpha.kubernetes.io/hostname: api.example.com external-dns.alpha.kubernetes.io/set-identifier: api-us-east-1 external-dns.alpha.kubernetes.io/aws-region: us-east-1 spec: type: LoadBalancer --- apiVersion: v1 kind: Service metadata: name: my-service-eu annotations: external-dns.alpha.kubernetes.io/hostname: api.example.com external-dns.alpha.kubernetes.io/set-identifier: api-eu-west-1 external-dns.alpha.kubernetes.io/aws-region: eu-west-1 spec: type: LoadBalancer ``` > Route53 will direct each user to the region with the lowest latency. ### Associating DNS records with healthchecks You can configure Route53 to associate DNS records with healthchecks for automated DNS failover using `external-dns.alpha.kubernetes.io/aws-health-check-id: ` annotation. Note: ExternalDNS does not support creating healthchecks, and assumes that `` already exists. ## Canonical Hosted Zones When creating ALIAS type records in Route53 it is required that external-dns be aware of the canonical hosted zone in which the specified hostname is created. External-dns is able to automatically identify the canonical hosted zone for many hostnames based upon known hostname suffixes which are defined in [aws.go](https://github.com/kubernetes-sigs/external-dns/blob/master/provider/aws/aws.go#L65). If a hostname does not have a known suffix then the suffix can be added into `aws.go` or the [target-hosted-zone annotation](#target-hosted-zone) can be used to manually define the ID of the canonical hosted zone. ## Govcloud caveats Due to the special nature with how Route53 runs in Govcloud, there are a few tweaks in the deployment settings. - An Environment variable with name of `AWS_REGION` set to either `us-gov-west-1` or `us-gov-east-1` is required. Otherwise it tries to lookup a region that does not exist in Govcloud and it errors out. ```yaml env: - name: AWS_REGION value: us-gov-west-1 ``` - Route53 in Govcloud does not allow aliases. Therefore, container args must be set so that it uses CNAMES and a txt-prefix must be set to something. Otherwise, it will try to create a TXT record with the same value than the CNAME itself, which is not allowed. ```yaml args: - --aws-prefer-cname - --txt-prefix={{ YOUR_PREFIX }} ``` - The first two changes are needed if you use Route53 in Govcloud, which only supports private zones. There are also no cross account IAM whatsoever between Govcloud and commercial AWS accounts. - If services and ingresses need to make Route 53 entries to an public zone in a commercial account, you will have set env variables of `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` with a key and secret to the commercial account that has the sufficient rights. ```yaml env: - name: AWS_ACCESS_KEY_ID value: XXXXXXXXX - name: AWS_SECRET_ACCESS_KEY valueFrom: secretKeyRef: name: {{ YOUR_SECRET_NAME }} key: {{ YOUR_SECRET_KEY }} ``` ## DynamoDB Registry The DynamoDB Registry can be used to store dns records metadata. See the [DynamoDB Registry Tutorial](../registry/dynamodb.md) for more information. ## Disable AAAA Record Creation If you would like ExternalDNS to not create AAAA records at all, you can add the following command line parameter: `--exclude-record-types=AAAA`. Please be aware, this will disable AAAA record creation even for dualstack enabled load balancers. ## Clean up Make sure to delete all Service objects before terminating the cluster so all load balancers get cleaned up correctly. ```bash kubectl delete service nginx ``` **IMPORTANT** If you attached a policy to the Node IAM Role, then you will want to detach this before deleting the EKS cluster. Otherwise, the role resource will be locked, and the cluster cannot be deleted, especially if it was provisioned by automation like `terraform` or `eksctl`. ```bash aws iam detach-role-policy --role-name $NODE_ROLE_NAME --policy-arn $POLICY_ARN ``` If the cluster was provisioned using `eksctl`, you can delete the cluster with: ```bash eksctl delete cluster --name $EKS_CLUSTER_NAME --region $EKS_CLUSTER_REGION ``` Give ExternalDNS some time to clean up the DNS records for you. Then delete the hosted zone if you created one for the testing purpose. ```bash aws route53 delete-hosted-zone --id $ZONE_ID # e.g /hostedzone/ZEWFWZ4R16P7IB ``` If IAM user credentials were used, you can remove the user with: ```bash aws iam detach-user-policy --user-name "externaldns" --policy-arn $POLICY_ARN # If static credentials were used aws iam delete-access-key --user-name "externaldns" --access-key-id $ACCESS_KEY_ID aws iam delete-user --user-name "externaldns" ``` If IRSA was used, you can remove the IRSA role with: ```bash aws iam detach-role-policy --role-name $IRSA_ROLE --policy-arn $POLICY_ARN aws iam delete-role --role-name $IRSA_ROLE ``` Delete any unneeded policies: ```bash aws iam delete-policy --policy-arn $POLICY_ARN ``` ## Throttling Route53 has a [5 API requests per second per account hard quota](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests-route-53). Running several fast polling ExternalDNS instances in a given account can easily hit that limit. Some ways to reduce the request rate include: - Reduce the polling loop's synchronization interval at the possible cost of slower change propagation (but see `--events` below to reduce the impact). - `--interval=5m` (default `1m`) - Enable a Cache to store the zone records list. It comes with a cost: slower propagation when the zone gets modified from other sources such as the AWS console, terraform, cloudformation or anything similar. - `--provider-cache-time=15m` (default `0m`) - Trigger the polling loop on changes to K8s objects, rather than only at `interval` and ensure a minimum of time between events, to have responsive updates with long poll intervals - `--events` - `--min-event-sync-interval=5m` (default `5s`) - Limit the [sources watched](https://github.com/kubernetes-sigs/external-dns/blob/master/pkg/apis/externaldns/types.go#L364) when the `--events` flag is specified to specific types, namespaces, labels, or annotations - `--source=ingress --source=service` - specify multiple times for multiple sources - `--namespace=my-app` - `--label-filter=app in (my-app)` - `--ingress-class=nginx-external` - Limit services watched by type (not applicable to ingress or other types) - `--service-type-filter=LoadBalancer` default `all` - Limit the hosted zones considered - `--zone-id-filter=ABCDEF12345678` - specify multiple times if needed - `--domain-filter=example.com` by domain suffix - specify multiple times if needed - `--regex-domain-filter=example*` by domain suffix but as a regex - overrides domain-filter - `--exclude-domains=ignore.this.example.com` to exclude a domain or subdomain - `--regex-domain-exclusion=ignore*` subtracts it's matches from `regex-domain-filter`'s matches - `--aws-zone-type=public` only sync zones of this type `[public|private]` - `--aws-zone-tags=owner=k8s` only sync zones with this tag - If the list of zones managed by ExternalDNS doesn't change frequently, cache it by setting a TTL. - `--aws-zones-cache-duration=3h` (default `0` - disabled) - Increase the number of changes applied to Route53 in each batch - `--aws-batch-change-size=4000` (default `1000`) - Increase the interval between changes - `--aws-batch-change-interval=10s` (default `1s`) - Introducing some jitter to the pod initialization, so that when multiple instances of ExternalDNS are updated at the same time they do not make their requests on the same second. A simple way to implement randomised startup is with an init container: ```yaml ... spec: initContainers: - name: init-jitter image: registry.k8s.io/external-dns/external-dns:v0.20.0 command: - /bin/sh - -c - 'FOR=$((RANDOM % 10))s;echo "Sleeping for $FOR";sleep $FOR' containers: ... ``` ### EKS An effective starting point for EKS with an ingress controller might look like: ```bash --interval=5m --events --source=ingress --domain-filter=example.com --aws-zones-cache-duration=1h ``` ### Batch size options After external-dns generates all changes, it will perform a task to group those changes into batches. Each change will be validated against batch-change-size limits. If at least one of those parameters out of range - the change will be moved to a separate batch. If the change can't fit into any batch - *it will be skipped.* There are 3 options to control batch size for AWS provider: - Maximum amount of changes added to one batch - `--aws-batch-change-size` (default `1000`) - Maximum size of changes in bytes added to one batch - `--aws-batch-change-size-bytes` (default `32000`) - Maximum value count of changes added to one batch - `aws-batch-change-size-values` (default `1000`) `aws-batch-change-size` can be very useful for throttling purposes and can be set to any value. Default values for flags `aws-batch-change-size-bytes` and `aws-batch-change-size-values` are taken from [AWS documentation](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests) for Route53 API. > [!WARNING] > **You should not change those values until you really have to.** Because those limits are in place, `aws-batch-change-size` can be set to any value: Even if your batch size is `4000` records, your change will be split to separate batches due to bytes/values size limits and apply request will be finished without issues. ## Using CRD source to manage DNS records in AWS Please refer to the [CRD source documentation](../sources/crd.md#example) for more information. ================================================ FILE: docs/tutorials/azure-private-dns.md ================================================ # Azure Private DNS This tutorial describes how to set up ExternalDNS for managing records in Azure Private DNS. It comprises of the following steps: 1) Provision Azure Private DNS 2) Configure service principal for managing the zone 3) Deploy ExternalDNS 4) Expose an NGINX service with a LoadBalancer and annotate it with the desired DNS name 5) Install NGINX Ingress Controller (Optional) 6) Expose an nginx service with an ingress (Optional) 7) Verify the DNS records Everything will be deployed on Kubernetes. Therefore, please see the subsequent prerequisites. ## Prerequisites - Azure Kubernetes Service is deployed and ready - [Azure CLI 2.0](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) and `kubectl` installed on the box to execute the subsequent steps ## Provision Azure Private DNS The provider will find suitable zones for domains it manages. It will not automatically create zones. For this tutorial, we will create a Azure resource group named 'externaldns' that can easily be deleted later. ```sh az group create -n externaldns -l westeurope ``` Substitute a more suitable location for the resource group if desired. As a prerequisite for Azure Private DNS to resolve records is to define links with VNETs. Thus, first create a VNET. ```sh $ az network vnet create \ --name myvnet \ --resource-group externaldns \ --location westeurope \ --address-prefix 10.2.0.0/16 \ --subnet-name mysubnet \ --subnet-prefixes 10.2.0.0/24 ``` Next, create a Azure Private DNS zone for "example.com": ```sh az network private-dns zone create -g externaldns -n example.com ``` Substitute a domain you own for "example.com" if desired. Finally, create the mentioned link with the VNET. ```sh $ az network private-dns link vnet create -g externaldns -n mylink \ -z example.com -v myvnet --registration-enabled false ``` ## Configure service principal for managing the zone ExternalDNS needs permissions to make changes in Azure Private DNS. These permissions are roles assigned to the service principal used by ExternalDNS. A service principal with a minimum access level of `Private DNS Zone Contributor` to the Private DNS zone(s) and `Reader` to the resource group containing the Azure Private DNS zone(s) is necessary. More powerful role-assignments like `Owner` or assignments on subscription-level work too. Start off by **creating the service principal** without role-assignments. ```sh $ az ad sp create-for-rbac --skip-assignment -n http://externaldns-sp { "appId": "appId GUID", <-- aadClientId value ... "password": "password", <-- aadClientSecret value "tenant": "AzureAD Tenant Id" <-- tenantId value } ``` > Note: Alternatively, you can issue `az account show --query "tenantId"` to retrieve the id of your AAD Tenant too. Next, assign the roles to the service principal. But first **retrieve the ID's** of the objects to assign roles on. ```sh # find out the resource ids of the resource group where the dns zone is deployed, and the dns zone itself $ az group show --name externaldns --query id -o tsv /subscriptions/id/resourceGroups/externaldns $ az network private-dns zone show --name example.com -g externaldns --query id -o tsv /subscriptions/.../resourceGroups/externaldns/providers/Microsoft.Network/privateDnsZones/example.com ``` Now, **create role assignments**. ```sh # 1. as a reader to the resource group $ az role assignment create --role "Reader" --assignee --scope # 2. as a contributor to DNS Zone itself $ az role assignment create --role "Private DNS Zone Contributor" --assignee --scope ``` ## Throttling When the ExternalDNS managed zones list doesn't change frequently, one can set `--azure-zones-cache-duration` (zones list cache time-to-live). The zones list cache is disabled by default, with a value of 0s. Also, one can leverage the built-in retry policies of the Azure SDK. The flag --azure-maxretries-count can be specified in the manifest yaml to configure behavior. The default value of Azure SDK retry is 3. ## Deploy ExternalDNS Configure `kubectl` to be able to communicate and authenticate with your cluster. This is per default done through the file `~/.kube/config`. For general background information on this see [kubernetes-docs](https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster/). Azure-CLI features functionality for automatically maintaining this file for AKS-Clusters. See [Azure-Docs](https://docs.microsoft.com/de-de/cli/azure/aks?view=azure-cli-latest#az-aks-get-credentials). Follow the steps for [azure-dns provider](./azure.md#creating-configuration-file) to create a configuration file. Then apply one of the following manifests depending on whether you use RBAC or not. The credentials of the service principal are provided to ExternalDNS as environment-variables. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: externaldns spec: selector: matchLabels: app: externaldns strategy: type: Recreate template: metadata: labels: app: externaldns spec: containers: - name: externaldns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com - --provider=azure-private-dns - --azure-resource-group=externaldns - --azure-subscription-id= - --azure-maxretries-count=1 # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3. volumeMounts: - name: azure-config-file mountPath: /etc/kubernetes readOnly: true volumes: - name: azure-config-file secret: secretName: azure-config-file ``` ### Manifest (for clusters with RBAC enabled, cluster access) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: externaldns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: externaldns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["get", "watch", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: externaldns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: externaldns subjects: - kind: ServiceAccount name: externaldns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: externaldns spec: selector: matchLabels: app: externaldns strategy: type: Recreate template: metadata: labels: app: externaldns spec: serviceAccountName: externaldns containers: - name: externaldns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com - --provider=azure-private-dns - --azure-resource-group=externaldns - --azure-subscription-id= - --azure-maxretries-count=1 # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3. volumeMounts: - name: azure-config-file mountPath: /etc/kubernetes readOnly: true volumes: - name: azure-config-file secret: secretName: azure-config-file ``` ### Manifest (for clusters with RBAC enabled, namespace access) This configuration is the same as above, except it only requires privileges for the current namespace, not for the whole cluster. However, access to [nodes](https://kubernetes.io/docs/concepts/architecture/nodes/) requires cluster access, so when using this manifest, services with type `NodePort` will be skipped! ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: externaldns --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: externaldns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: externaldns roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: externaldns subjects: - kind: ServiceAccount name: externaldns --- apiVersion: apps/v1 kind: Deployment metadata: name: externaldns spec: selector: matchLabels: app: externaldns strategy: type: Recreate template: metadata: labels: app: externaldns spec: serviceAccountName: externaldns containers: - name: externaldns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com - --provider=azure-private-dns - --azure-resource-group=externaldns - --azure-subscription-id= - --azure-maxretries-count=1 # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3. volumeMounts: - name: azure-config-file mountPath: /etc/kubernetes readOnly: true volumes: - name: azure-config-file secret: secretName: azure-config-file ``` Create the deployment for ExternalDNS: ```sh kubectl create -f externaldns.yaml ``` ## Create an nginx deployment This step creates a demo workload in your cluster. Apply the following manifest to create a deployment that we are going to expose later in this tutorial in multiple ways: ```yaml --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 ``` ## Expose the nginx deployment with a load balancer Apply the following manifest to create a service of type `LoadBalancer`. This will create a public load balancer in Azure that will forward traffic to the nginx pods. ```yaml --- apiVersion: v1 kind: Service metadata: name: nginx-svc annotations: service.beta.kubernetes.io/azure-load-balancer-internal: "true" external-dns.alpha.kubernetes.io/hostname: server.example.com external-dns.alpha.kubernetes.io/internal-hostname: server-clusterip.example.com spec: ports: - port: 80 protocol: TCP targetPort: 80 selector: app: nginx type: LoadBalancer ``` In the service we used multiple annotations. The annotation `service.beta.kubernetes.io/azure-load-balancer-internal` is used to create an internal load balancer. The annotation `external-dns.alpha.kubernetes.io/hostname` is used to create a DNS record for the load balancer that will point to the internal IP address in the VNET allocated by the internal load balancer. The annotation `external-dns.alpha.kubernetes.io/internal-hostname` is used to create a private DNS record for the load balancer that will point to the cluster IP. ## Install NGINX Ingress Controller (Optional) Helm is used to deploy the ingress controller. We employ the popular chart [ingress-nginx](https://github.com/kubernetes/ingress-nginx/tree/main/charts/ingress-nginx). ```sh $ helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx $ helm repo update $ helm install [RELEASE_NAME] ingress-nginx/ingress-nginx --set controller.publishService.enabled=true ``` The parameter `controller.publishService.enabled` needs to be set to `true.` It will make the ingress controller update the endpoint records of ingress-resources to contain the external-ip of the loadbalancer serving the ingress-controller. This is crucial as ExternalDNS reads those endpoints records when creating DNS-Records from ingress-resources. In the subsequent parameter we will make use of this. If you don't want to work with ingress-resources in your later use, you can leave the parameter out. Verify the correct propagation of the loadbalancer's ip by listing the ingresses. ```sh kubectl get ingress ``` The address column should contain the ip for each ingress. ExternalDNS will pick up exactly this piece of information. ```sh NAME HOSTS ADDRESS PORTS AGE nginx1 sample1.aks.com 52.167.195.110 80 6d22h nginx2 sample2.aks.com 52.167.195.110 80 6d21h ``` If you do not want to deploy the ingress controller with Helm, ensure to pass the following cmdline-flags to it through the mechanism of your choice: ```sh flags: --publish-service=/ --update-status=true (default-value) example: ./nginx-ingress-controller --publish-service=default/nginx-ingress-controller ``` ## Expose the nginx deployment with the ingress (Optional) Apply the following manifest to create an ingress resource that will expose the nginx deployment. The ingress resource backend points to a `ClusterIP` service that is needed to select the pods that will receive the traffic. ```yaml --- apiVersion: v1 kind: Service metadata: name: nginx-svc-clusterip spec: ports: - port: 80 protocol: TCP targetPort: 80 selector: app: nginx type: ClusterIP --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx spec: ingressClassName: nginx rules: - host: server.example.com http: paths: - backend: service: name: nginx-svc-clusterip port: number: 80 pathType: Prefix ``` When you use ExternalDNS with Ingress resources, it automatically creates DNS records based on the hostnames listed in those Ingress objects. Those hostnames must match the filters that you defined (if any): - By default, `--domain-filter` filters Azure Private DNS zone. - If you use `--domain-filter` together with `--zone-name-filter`, the behavior changes: `--domain-filter` then filters Ingress domains, not the Azure Private DNS zone name. When those hostnames are removed or renamed the corresponding DNS records are also altered. Create the deployment, service and ingress object: ```sh kubectl create -f nginx.yaml ``` Since your external IP would have already been assigned to the nginx-ingress service, the DNS records pointing to the IP of the nginx-ingress service should be created within a minute. ## Verify created records Run the following command to view the A records for your Azure Private DNS zone: ```sh az network private-dns record-set a list -g externaldns -z example.com ``` Substitute the zone for the one created above if a different domain was used. This should show the external IP address of the service as the A record for your domain ('@' indicates the record is for the zone itself). ================================================ FILE: docs/tutorials/azure.md ================================================ # Azure DNS This tutorial describes how to setup ExternalDNS for [Azure DNS](https://azure.microsoft.com/services/dns/) with [Azure Kubernetes Service](https://docs.microsoft.com/azure/aks/). Make sure to use **>=0.11.0** version of ExternalDNS for this tutorial. This tutorial uses [Azure CLI 2.0](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli) for all Azure commands and assumes that the Kubernetes cluster was created via Azure Container Services and `kubectl` commands are being run on an orchestration node. ## Creating an Azure DNS zone The Azure provider for ExternalDNS will find suitable zones for domains it manages; it will not automatically create zones. For this tutorial, we will create a Azure resource group named `MyDnsResourceGroup` that can easily be deleted later: ```bash az group create --name "MyDnsResourceGroup" --location "eastus" ``` Substitute a more suitable location for the resource group if desired. Next, create a Azure DNS zone for `example.com`: ```bash az network dns zone create --resource-group "MyDnsResourceGroup" --name "example.com" ``` Substitute a domain you own for `example.com` if desired. If using your own domain that was registered with a third-party domain registrar, you should point your domain's name servers to the values in the `nameServers` field from the JSON data returned by the `az network dns zone create` command. Please consult your registrar's documentation on how to do that. ### Internal Load Balancer To create internal load balancers, one can set the annotation `service.beta.kubernetes.io/azure-load-balancer-internal` to `true` on the resource. **Note**: AKS cluster's control plane managed identity needs to be granted `Network Contributor` role to update the subnet. For more details refer to [Use an internal load balancer with Azure Kubernetes Service (AKS)](https://learn.microsoft.com/en-us/azure/aks/internal-lb) ## Configuration file The azure provider will reference a configuration file called `azure.json`. The preferred way to inject the configuration file is by using a Kubernetes secret. The secret should contain an object named `azure.json` with content similar to this: ```json { "tenantId": "01234abc-de56-ff78-abc1-234567890def", "subscriptionId": "01234abc-de56-ff78-abc1-234567890def", "resourceGroup": "MyDnsResourceGroup", "aadClientId": "01234abc-de56-ff78-abc1-234567890def", "aadClientSecret": "uKiuXeiwui4jo9quae9o" } ``` The following fields are used: * `tenantId` (**required**) - run `az account show --query "tenantId"` or by selecting Azure Active Directory in the Azure Portal and checking the _Directory ID_ under Properties. * `subscriptionId` (**required**) - run `az account show --query "id"` or by selecting Subscriptions in the Azure Portal. * `resourceGroup` (**required**) is the Resource Group created in a previous step that contains the Azure DNS Zone. * `aadClientID` is associated with the Service Principal. This is used with Service Principal or Workload Identity methods documented in the next section. * `aadClientSecret` is associated with the Service Principal. This is only used with Service Principal method documented in the next section. * `useManagedIdentityExtension` - this is set to `true` if you use either AKS Kubelet Identity or AAD Pod Identities methods documented in the next section. * `userAssignedIdentityID` - this contains the client id from the Managed identity when using the AAD Pod Identities method documented in the next setion. * `activeDirectoryAuthorityHost` - this contains the URI to override the default Azure Active Directory authority endpoint. This is useful for Azure Stack Cloud deployments or custom environments. * `useWorkloadIdentityExtension` - this is set to `true` if you use Workload Identity method documented in the next section. * `ResourceManagerAudience` - this specifies the audience for the Azure Resource Manager service when using Azure Stack Cloud. This is required for Azure Stack Cloud deployments to authenticate with the correct Resource Manager endpoint. * `ResourceManagerEndpoint` - this specifies the endpoint URL for the Azure Resource Manager service when using Azure Stack Cloud. This is required for Azure Stack Cloud deployments to point to the correct Resource Manager instance. The Azure DNS provider expects, by default, that the configuration file is at `/etc/kubernetes/azure.json`. This can be overridden with the `--azure-config-file` option when starting ExternalDNS. ## Permissions to modify DNS zone ExternalDNS needs permissions to make changes to the Azure DNS zone. There are four ways configure the access needed: * [Service Principal](#service-principal) * [Managed Identity Using AKS Kubelet Identity](#managed-identity-using-aks-kubelet-identity) * [Managed Identity Using AAD Pod Identities](#managed-identity-using-aad-pod-identities) * [Managed Identity Using Workload Identity](#managed-identity-using-workload-identity) ### Service Principal These permissions are defined in a Service Principal that should be made available to ExternalDNS as a configuration file `azure.json`. #### Creating a service principal A Service Principal with a minimum access level of `DNS Zone Contributor` or `Contributor` to the DNS zone(s) and `Reader` to the resource group containing the Azure DNS zone(s) is necessary for ExternalDNS to be able to edit DNS records. However, other more permissive access levels will work too (e.g. `Contributor` to the resource group or the whole subscription). This is an Azure CLI example on how to query the Azure API for the information required for the Resource Group and DNS zone you would have already created in previous steps (requires `azure-cli` and `jq`) ```bash $ EXTERNALDNS_NEW_SP_NAME="ExternalDnsServicePrincipal" # name of the service principal $ AZURE_DNS_ZONE_RESOURCE_GROUP="MyDnsResourceGroup" # name of resource group where dns zone is hosted $ AZURE_DNS_ZONE="example.com" # DNS zone name like example.com or sub.example.com # Create the service principal $ DNS_SP=$(az ad sp create-for-rbac --name $EXTERNALDNS_NEW_SP_NAME) $ EXTERNALDNS_SP_APP_ID=$(echo $DNS_SP | jq -r '.appId') $ EXTERNALDNS_SP_PASSWORD=$(echo $DNS_SP | jq -r '.password') ``` #### Assign the rights for the service principal Grant access to Azure DNS zone for the service principal. ```bash # fetch DNS id used to grant access to the service principal DNS_ID=$(az network dns zone show --name $AZURE_DNS_ZONE \ --resource-group $AZURE_DNS_ZONE_RESOURCE_GROUP --query "id" --output tsv) # 1. as a reader to the resource group $ az role assignment create --role "Reader" --assignee $EXTERNALDNS_SP_APP_ID --scope $DNS_ID # 2. as a contributor to DNS Zone itself $ az role assignment create --role "Contributor" --assignee $EXTERNALDNS_SP_APP_ID --scope $DNS_ID ``` #### Creating a configuration file for the service principal Create the file `azure.json` with values gather from previous steps. ```bash cat <<-EOF > /local/path/to/azure.json { "tenantId": "$(az account show --query tenantId -o tsv)", "subscriptionId": "$(az account show --query id -o tsv)", "resourceGroup": "$AZURE_DNS_ZONE_RESOURCE_GROUP", "aadClientId": "$EXTERNALDNS_SP_APP_ID", "aadClientSecret": "$EXTERNALDNS_SP_PASSWORD" } EOF ``` Use this file to create a Kubernetes secret: ```bash kubectl create secret generic azure-config-file --namespace "default" --from-file /local/path/to/azure.json ``` ### Managed identity using AKS Kubelet identity The [managed identity](https://docs.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview) that is assigned to the underlying node pool in the AKS cluster can be given permissions to access Azure DNS. Managed identities are essentially a service principal whose lifecycle is managed, such as deleting the AKS cluster will also delete the service principals associated with the AKS cluster. The managed identity assigned Kubernetes node pool, or specifically the [VMSS](https://docs.microsoft.com/azure/virtual-machine-scale-sets/overview), is called the Kubelet identity. The managed identites were previously called MSI (Managed Service Identity) and are enabled by default when creating an AKS cluster. Note that permissions granted to this identity will be accessible to all containers running inside the Kubernetes cluster, not just the ExternalDNS container(s). For the managed identity, the contents of `azure.json` should be similar to this: ```json { "tenantId": "01234abc-de56-ff78-abc1-234567890def", "subscriptionId": "01234abc-de56-ff78-abc1-234567890def", "resourceGroup": "MyDnsResourceGroup", "useManagedIdentityExtension": true, "userAssignedIdentityID": "01234abc-de56-ff78-abc1-234567890def" } ``` #### Fetching the Kubelet identity For this process, you will need to get the kubelet identity: ```bash $ PRINCIPAL_ID=$(az aks show --resource-group $CLUSTER_GROUP --name $CLUSTERNAME \ --query "identityProfile.kubeletidentity.objectId" --output tsv) $ IDENTITY_CLIENT_ID=$(az aks show --resource-group $CLUSTER_GROUP --name $CLUSTERNAME \ --query "identityProfile.kubeletidentity.clientId" --output tsv) ``` #### Assign rights for the Kubelet identity Grant access to Azure DNS zone for the kubelet identity. ```bash $ AZURE_DNS_ZONE="example.com" # DNS zone name like example.com or sub.example.com $ AZURE_DNS_ZONE_RESOURCE_GROUP="MyDnsResourceGroup" # resource group where DNS zone is hosted # fetch DNS id used to grant access to the kubelet identity $ DNS_ID=$(az network dns zone show --name $AZURE_DNS_ZONE \ --resource-group $AZURE_DNS_ZONE_RESOURCE_GROUP --query "id" --output tsv) $ az role assignment create --role "DNS Zone Contributor" --assignee $PRINCIPAL_ID --scope $DNS_ID ``` #### Creating a configuration file for the kubelet identity Create the file `azure.json` with values gather from previous steps. ```bash cat <<-EOF > /local/path/to/azure.json { "tenantId": "$(az account show --query tenantId -o tsv)", "subscriptionId": "$(az account show --query id -o tsv)", "resourceGroup": "$AZURE_DNS_ZONE_RESOURCE_GROUP", "useManagedIdentityExtension": true, "userAssignedIdentityID": "$IDENTITY_CLIENT_ID" } EOF ``` Use the `azure.json` file to create a Kubernetes secret: ```bash kubectl create secret generic azure-config-file --namespace "default" --from-file /local/path/to/azure.json ``` ### Managed identity using AAD Pod Identities For this process, we will create a [managed identity](https://docs.microsoft.com//azure/active-directory/managed-identities-azure-resources/overview) that will be explicitly used by the ExternalDNS container. This process is similar to Kubelet identity except that this managed identity is not associated with the Kubernetes node pool, but rather associated with explicit ExternalDNS containers. #### Enable the AAD Pod Identities feature For this solution, [AAD Pod Identities](https://docs.microsoft.com/azure/aks/use-azure-ad-pod-identity) preview feature can be enabled. The commands below should do the trick to enable this feature: ```bash az feature register --name EnablePodIdentityPreview --namespace Microsoft.ContainerService az feature register --name AutoUpgradePreview --namespace Microsoft.ContainerService az extension add --name aks-preview az extension update --name aks-preview az provider register --namespace Microsoft.ContainerService ``` #### Deploy the AAD Pod Identities service Once enabled, you can update your cluster and install needed services for the [AAD Pod Identities](https://docs.microsoft.com/azure/aks/use-azure-ad-pod-identity) feature. ```bash AZURE_AKS_RESOURCE_GROUP="my-aks-cluster-group" # name of resource group where aks cluster was created AZURE_AKS_CLUSTER_NAME="my-aks-cluster" # name of aks cluster previously created az aks update --resource-group ${AZURE_AKS_RESOURCE_GROUP} --name ${AZURE_AKS_CLUSTER_NAME} --enable-pod-identity ``` Note that, if you use the default network plugin `kubenet`, then you need to add the command line option `--enable-pod-identity-with-kubenet` to the above command. #### Creating the managed identity After this process is finished, create a managed identity. ```bash $ IDENTITY_RESOURCE_GROUP=$AZURE_AKS_RESOURCE_GROUP # custom group or reuse AKS group $ IDENTITY_NAME="example-com-identity" # create a managed identity $ az identity create --resource-group "${IDENTITY_RESOURCE_GROUP}" --name "${IDENTITY_NAME}" ``` #### Assign rights for the managed identity Grant access to Azure DNS zone for the managed identity. ```bash $ AZURE_DNS_ZONE_RESOURCE_GROUP="MyDnsResourceGroup" # name of resource group where dns zone is hosted $ AZURE_DNS_ZONE="example.com" # DNS zone name like example.com or sub.example.com # fetch identity client id from managed identity created earlier $ IDENTITY_CLIENT_ID=$(az identity show --resource-group "${IDENTITY_RESOURCE_GROUP}" \ --name "${IDENTITY_NAME}" --query "clientId" --output tsv) # fetch DNS id used to grant access to the managed identity $ DNS_ID=$(az network dns zone show --name "${AZURE_DNS_ZONE}" \ --resource-group "${AZURE_DNS_ZONE_RESOURCE_GROUP}" --query "id" --output tsv) $ az role assignment create --role "DNS Zone Contributor" \ --assignee "${IDENTITY_CLIENT_ID}" --scope "${DNS_ID}" ``` #### Creating a configuration file for the managed identity Create the file `azure.json` with the values from previous steps: ```bash cat <<-EOF > /local/path/to/azure.json { "tenantId": "$(az account show --query tenantId -o tsv)", "subscriptionId": "$(az account show --query id -o tsv)", "resourceGroup": "$AZURE_DNS_ZONE_RESOURCE_GROUP", "useManagedIdentityExtension": true, "userAssignedIdentityID": "$IDENTITY_CLIENT_ID" } EOF ``` Use the `azure.json` file to create a Kubernetes secret: ```bash kubectl create secret generic azure-config-file --namespace "default" --from-file /local/path/to/azure.json ``` #### Creating an Azure identity binding A binding between the managed identity and the ExternalDNS pods needs to be setup by creating `AzureIdentity` and `AzureIdentityBinding` resources. This will allow appropriately labeled ExternalDNS pods to authenticate using the managed identity. When AAD Pod Identity feature is enabled from previous steps above, the `az aks pod-identity add` can be used to create these resources: ```bash $ IDENTITY_RESOURCE_ID=$(az identity show --resource-group ${IDENTITY_RESOURCE_GROUP} \ --name ${IDENTITY_NAME} --query id --output tsv) $ az aks pod-identity add --resource-group ${AZURE_AKS_RESOURCE_GROUP} \ --cluster-name ${AZURE_AKS_CLUSTER_NAME} --namespace "default" \ --name "external-dns" --identity-resource-id ${IDENTITY_RESOURCE_ID} ``` This will add something similar to the following resources: ```yaml apiVersion: aadpodidentity.k8s.io/v1 kind: AzureIdentity metadata: labels: addonmanager.kubernetes.io/mode: Reconcile kubernetes.azure.com/managedby: aks name: external-dns spec: clientID: $IDENTITY_CLIENT_ID resourceID: $IDENTITY_RESOURCE_ID type: 0 --- apiVersion: aadpodidentity.k8s.io/v1 kind: AzureIdentityBinding metadata: annotations: labels: addonmanager.kubernetes.io/mode: Reconcile kubernetes.azure.com/managedby: aks name: external-dns-binding spec: azureIdentity: external-dns selector: external-dns ``` #### Update ExternalDNS labels When deploying ExternalDNS, you want to make sure that deployed pod(s) will have the label `aadpodidbinding: external-dns` to enable AAD Pod Identities. You can patch an existing deployment of ExternalDNS with this command: ```bash kubectl patch deployment external-dns --namespace "default" --patch \ '{"spec": {"template": {"metadata": {"labels": {"aadpodidbinding": "external-dns"}}}}}' ``` ### Managed identity using Workload Identity For this process, we will create a [managed identity](https://docs.microsoft.com//azure/active-directory/managed-identities-azure-resources/overview) that will be explicitly used by the ExternalDNS container. This process is somewhat similar to Pod Identity except that this managed identity is associated with a kubernetes service account. #### Deploy OIDC issuer and Workload Identity services Update your cluster to install [OIDC Issuer](https://learn.microsoft.com/en-us/azure/aks/use-oidc-issuer) and [Workload Identity](https://learn.microsoft.com/en-us/azure/aks/workload-identity-deploy-cluster): ```bash AZURE_AKS_RESOURCE_GROUP="my-aks-cluster-group" # name of resource group where aks cluster was created AZURE_AKS_CLUSTER_NAME="my-aks-cluster" # name of aks cluster previously created az aks update --resource-group ${AZURE_AKS_RESOURCE_GROUP} --name ${AZURE_AKS_CLUSTER_NAME} --enable-oidc-issuer --enable-workload-identity ``` #### Create a managed identity Create a managed identity: ```bash $ IDENTITY_RESOURCE_GROUP=$AZURE_AKS_RESOURCE_GROUP # custom group or reuse AKS group $ IDENTITY_NAME="example-com-identity" # create a managed identity $ az identity create --resource-group "${IDENTITY_RESOURCE_GROUP}" --name "${IDENTITY_NAME}" ``` #### Assign a role to the managed identity Grant access to Azure DNS zone for the managed identity: ```bash $ AZURE_DNS_ZONE_RESOURCE_GROUP="MyDnsResourceGroup" # name of resource group where dns zone is hosted $ AZURE_DNS_ZONE="example.com" # DNS zone name like example.com or sub.example.com # fetch identity client id from managed identity created earlier $ IDENTITY_CLIENT_ID=$(az identity show --resource-group "${IDENTITY_RESOURCE_GROUP}" \ --name "${IDENTITY_NAME}" --query "clientId" --output tsv) # fetch DNS id used to grant access to the managed identity $ DNS_ID=$(az network dns zone show --name "${AZURE_DNS_ZONE}" \ --resource-group "${AZURE_DNS_ZONE_RESOURCE_GROUP}" --query "id" --output tsv) $ RESOURCE_GROUP_ID=$(az group show --name "${AZURE_DNS_ZONE_RESOURCE_GROUP}" --query "id" --output tsv) $ az role assignment create --role "DNS Zone Contributor" \ --assignee "${IDENTITY_CLIENT_ID}" --scope "${DNS_ID}" $ az role assignment create --role "Reader" \ --assignee "${IDENTITY_CLIENT_ID}" --scope "${RESOURCE_GROUP_ID}" ``` #### Create a federated identity credential A binding between the managed identity and the ExternalDNS service account needs to be setup by creating a federated identity resource: ```bash OIDC_ISSUER_URL="$(az aks show -n myAKSCluster -g myResourceGroup --query "oidcIssuerProfile.issuerUrl" -otsv)" az identity federated-credential create --name ${IDENTITY_NAME} --identity-name ${IDENTITY_NAME} --resource-group $AZURE_AKS_RESOURCE_GROUP} --issuer "$OIDC_ISSUER_URL" --subject "system:serviceaccount:default:external-dns" ``` NOTE: make sure federated credential refers to correct namespace and service account (`system:serviceaccount::`) #### Helm When deploying external-dns with Helm you need to create a secret to store the Azure config (see below) and create a workload identity (out of scope here) before you can install the chart. ```yaml apiVersion: v1 kind: Secret metadata: name: external-dns-azure type: Opaque data: azure.json: | { "tenantId": "", "subscriptionId": "", "resourceGroup": "", "useWorkloadIdentityExtension": true } ``` Once you have created the secret and have a workload identity you can install the chart with the following values. ```yaml fullnameOverride: external-dns serviceAccount: labels: azure.workload.identity/use: "true" annotations: azure.workload.identity/client-id: podLabels: azure.workload.identity/use: "true" extraVolumes: - name: azure-config-file secret: secretName: external-dns-azure extraVolumeMounts: - name: azure-config-file mountPath: /etc/kubernetes readOnly: true provider: name: azure ``` NOTE: make sure the pod is restarted whenever you make a configuration change. #### kubectl (alternative) ##### Create a configuration file for the managed identity Create the file `azure.json` with the values from previous steps: ```bash cat <<-EOF > /local/path/to/azure.json { "subscriptionId": "$(az account show --query id -o tsv)", "resourceGroup": "$AZURE_DNS_ZONE_RESOURCE_GROUP", "useWorkloadIdentityExtension": true } EOF ``` NOTE: it's also possible to specify (or override) ClientID specified in the next section through `aadClientId` field in this `azure.json` file. Use the `azure.json` file to create a Kubernetes secret: ```bash kubectl create secret generic azure-config-file --namespace "default" --from-file /local/path/to/azure.json ``` ##### Update labels and annotations on ExternalDNS service account To instruct Workload Identity webhook to inject a projected token into the ExternalDNS pod, the pod needs to have a label `azure.workload.identity/use: "true"` (before Workload Identity 1.0.0, this label was supposed to be set on the service account instead). Also, the service account needs to have an annotation `azure.workload.identity/client-id: `: To patch the existing serviceaccount and deployment, use the following command: ```bash $ kubectl patch serviceaccount external-dns --namespace "default" --patch \ "{\"metadata\": {\"annotations\": {\"azure.workload.identity/client-id\": \"${IDENTITY_CLIENT_ID}\"}}}" $ kubectl patch deployment external-dns --namespace "default" --patch \ '{"spec": {"template": {"metadata": {"labels": {\"azure.workload.identity/use\": \"true\"}}}}}' ``` NOTE: it's also possible to specify (or override) ClientID through `aadClientId` field in `azure.json`. NOTE: make sure the pod is restarted whenever you make a configuration change. ## Throttling When the ExternalDNS managed zones list doesn't change frequently, one can set `--azure-zones-cache-duration` (zones list cache time-to-live). The zones list cache is disabled by default, with a value of 0s. Also, one can leverage the built-in retry policies of the Azure SDK with a tunable maxRetries value. Environment variable AZURE_SDK_MAX_RETRIES can be specified in the manifest yaml to configure behavior. The defualt value of Azure SDK retry is 3. ## Ingress used with ExternalDNS This deployment assumes that you will be using nginx-ingress. When using nginx-ingress do not deploy it as a Daemon Set. This causes nginx-ingress to write the Cluster IP of the backend pods in the ingress status.loadbalancer.ip property which then has external-dns write the Cluster IP(s) in DNS vs. the nginx-ingress service external IP. Ensure that your nginx-ingress deployment has the following arg: added to it: ```sh - --publish-service=namespace/nginx-ingress-controller-svcname ``` For more details see here: [nginx-ingress external-dns](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/docs/faq.md#why-is-externaldns-only-adding-a-single-ip-address-in-route-53-on-aws-when-using-the-nginx-ingress-controller-how-do-i-get-it-to-use-the-fqdn-of-the-elb-assigned-to-my-nginx-ingress-controller-service-instead) ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. The deployment assumes that ExternalDNS will be installed into the `default` namespace. If this namespace is different, the `ClusterRoleBinding` will need to be updated to reflect the desired alternative namespace, such as `external-dns`, `kube-addons`, etc. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=azure - --azure-resource-group=MyDnsResourceGroup # (optional) use the DNS zones from the tutorial's resource group - --azure-maxretries-count=1 # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3. volumeMounts: - name: azure-config-file mountPath: /etc/kubernetes readOnly: true volumes: - name: azure-config-file secret: secretName: azure-config-file ``` ### Manifest (for clusters with RBAC enabled, cluster access) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods", "nodes"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=azure - --azure-resource-group=MyDnsResourceGroup # (optional) use the DNS zones from the tutorial's resource group - --txt-prefix=externaldns- - --azure-maxretries-count=1 # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3. volumeMounts: - name: azure-config-file mountPath: /etc/kubernetes readOnly: true volumes: - name: azure-config-file secret: secretName: azure-config-file ``` ### Manifest (for clusters with RBAC enabled, namespace access) This configuration is the same as above, except it only requires privileges for the current namespace, not for the whole cluster. However, access to [nodes](https://kubernetes.io/docs/concepts/architecture/nodes/) requires cluster access, so when using this manifest, services with type `NodePort` will be skipped! ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: external-dns roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: external-dns subjects: - kind: ServiceAccount name: external-dns --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=azure - --azure-resource-group=MyDnsResourceGroup # (optional) use the DNS zones from the tutorial's resource group - --azure-maxretries-count=1 # (optional) specifies the maxRetires value to be used by the Azure SDK. Default is 3. volumeMounts: - name: azure-config-file mountPath: /etc/kubernetes readOnly: true volumes: - name: azure-config-file secret: secretName: azure-config-file ``` Create the deployment for ExternalDNS: ```bash kubectl create --namespace "default" --filename externaldns.yaml ``` ## Ingress Option: Expose an nginx service with an ingress Create a file called `nginx.yaml` with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx-svc spec: ports: - port: 80 protocol: TCP targetPort: 80 selector: app: nginx type: ClusterIP --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx spec: ingressClassName: nginx rules: - host: server.example.com http: paths: - path: / pathType: Prefix backend: service: name: nginx-svc port: number: 80 ``` When you use ExternalDNS with Ingress resources, it automatically creates DNS records based on the hostnames listed in those Ingress objects. Those hostnames must match the filters that you defined (if any): * By default, `--domain-filter` filters Azure DNS zone. * If you use `--domain-filter` together with `--zone-name-filter`, the behavior changes: `--domain-filter` then filters Ingress domains, not the Azure DNS zone name. When those hostnames are removed or renamed the corresponding DNS records are also altered. Create the deployment, service and ingress object: ```bash kubectl create --namespace "default" --filename nginx.yaml ``` Since your external IP would have already been assigned to the nginx-ingress service, the DNS records pointing to the IP of the nginx-ingress service should be created within a minute. ## Azure Load Balancer option: Expose an nginx service with a load balancer Create a file called `nginx.yaml` with the following contents: ```yaml --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx-svc annotations: external-dns.alpha.kubernetes.io/hostname: server.example.com spec: ports: - port: 80 protocol: TCP targetPort: 80 selector: app: nginx type: LoadBalancer ``` The annotation `external-dns.alpha.kubernetes.io/hostname` is used to specify the DNS name that should be created for the service. The annotation value is a comma separated list of host names. ## Verifying Azure DNS records Run the following command to view the A records for your Azure DNS zone: ```bash az network dns record-set a list --resource-group "MyDnsResourceGroup" --zone-name example.com ``` Substitute the zone for the one created above if a different domain was used. This should show the external IP address of the service as the A record for your domain ('@' indicates the record is for the zone itself). ## Delete Azure Resource Group Now that we have verified that ExternalDNS will automatically manage Azure DNS records, we can delete the tutorial's resource group: ```bash az group delete --name "MyDnsResourceGroup" ``` ## More tutorials A video explanation is available here: https://www.youtube.com/watch?v=VSn6DPKIhM8&list=PLpbcUe4chE79sB7Jg7B4z3HytqUUEwcNE ![image](https://user-images.githubusercontent.com/6548359/235437721-87611869-75f2-4f32-bb35-9da585e46299.png) ================================================ FILE: docs/tutorials/civo.md ================================================ # Civo DNS This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Civo DNS Manager. Make sure to use **>0.13.5** version of ExternalDNS for this tutorial. ## Managing DNS with Civo If you want to learn about how to use Civo DNS Manager read the following tutorials: [An Introduction to Managing DNS](https://www.civo.com/learn/configure-dns) ## Get Civo Token Copy the token in the settings for your account The environment variable `CIVO_TOKEN` will be needed to run ExternalDNS with Civo. ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=civo env: - name: CIVO_TOKEN value: "YOUR_CIVO_API_TOKEN" ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=civo env: - name: CIVO_TOKEN value: "YOUR_CIVO_API_TOKEN" ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: my-app.example.com spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Note the annotation on the service; use the same hostname as the Civo DNS zone created above. ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. Create the deployment and service: ```console kubectl create -f nginx.yaml ``` Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Civo DNS records. ## Verifying Civo DNS records Check your [Civo UI](https://www.civo.com/account/dns) to view the records for your Civo DNS zone. Click on the zone for the one created above if a different domain was used. This should show the external IP address of the service as the A record for your domain. ## Cleanup Now that we have verified that ExternalDNS will automatically manage Civo DNS records, we can delete the tutorial's example: ```sh kubectl delete service -f nginx.yaml kubectl delete service -f externaldns.yaml ``` ================================================ FILE: docs/tutorials/cloudflare.md ================================================ # Cloudflare DNS This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Cloudflare DNS. Make sure to use **>=0.4.2** version of ExternalDNS for this tutorial. ## Creating a Cloudflare DNS zone We highly recommend to read this tutorial if you haven't used Cloudflare before: [Create a Cloudflare account and add a website](https://support.cloudflare.com/hc/en-us/articles/201720164-Step-2-Create-a-Cloudflare-account-and-add-a-website) ## Creating Cloudflare Credentials Snippet from [Cloudflare - Getting Started](https://api.cloudflare.com/#getting-started-endpoints): > Cloudflare's API exposes the entire Cloudflare infrastructure via a standardized programmatic interface. Using Cloudflare's API, you can do just about anything you can do on cloudflare.com via the customer dashboard. > The Cloudflare API is a RESTful API based on HTTPS requests and JSON responses. If you are registered with Cloudflare, you can obtain your API key from the bottom of the "My Account" page, found here: [Go to My account](https://dash.cloudflare.com/profile). API Token will be preferred for authentication if `CF_API_TOKEN` environment variable is set. Otherwise `CF_API_KEY` and `CF_API_EMAIL` should be set to run ExternalDNS with Cloudflare. You may provide the Cloudflare API token through a file by setting the `CF_API_TOKEN="file:/path/to/token"`. Note. The `CF_API_KEY` and `CF_API_EMAIL` should not be present, if you are using a `CF_API_TOKEN`. When using API Token authentication, the token should be granted Zone `Read`, DNS `Edit` privileges, and access to `All zones`. If you would like to further restrict the API permissions to a specific zone (or zones), you also need to use the `--zone-id-filter` so that the underlying API requests only access the zones that you explicitly specify, as opposed to accessing all zones. ## Throttling Cloudflare API has a [global rate limit of 1,200 requests per five minutes](https://developers.cloudflare.com/fundamentals/api/reference/limits/). Running several fast polling ExternalDNS instances in a given account can easily hit that limit. The AWS Provider [docs](./aws.md#throttling) has some recommendations that can be followed here too, but in particular, consider passing `--cloudflare-dns-records-per-page` with a high value (maximum is 5,000). ## Batch API The Cloudflare provider submits DNS record changes using Cloudflare's [Batch DNS Records API](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/batch/). All creates, updates, and deletes for a zone are grouped into transactional chunks and sent in a single API call per chunk, significantly reducing the total number of requests made. The batch API is transactional — if a chunk fails, the entire chunk is rolled back by Cloudflare. In that case, ExternalDNS automatically retries each record change in the chunk individually. Record types that are not supported by the batch PUT operation (e.g. SRV, CAA) are always submitted individually rather than through the batch API. | Flag | Default | Description | | :--- | :------ | :---------- | | `--batch-change-size` | `200` | Maximum number of DNS operations (creates + updates + deletes) per batch chunk. | | `--batch-change-interval` | `1s` | Pause between consecutive batch chunks. | ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Begin by creating a Kubernetes secret to securely store your CloudFlare API key. This key will enable ExternalDNS to authenticate with CloudFlare: ```shell kubectl create secret generic cloudflare-api-key --from-literal=apiKey=YOUR_API_KEY --from-literal=email=YOUR_CLOUDFLARE_EMAIL ``` And for API Token it should look like : ```shell kubectl create secret generic cloudflare-api-key --from-literal=apiKey=YOUR_API_TOKEN ``` Ensure to replace YOUR_API_KEY with your actual CloudFlare API key and YOUR_CLOUDFLARE_EMAIL with the email associated with your CloudFlare account. Then apply one of the following manifests file to deploy ExternalDNS. ### Using Helm Create a values.yaml file to configure ExternalDNS to use CloudFlare as the DNS provider. This file should include the necessary environment variables: ```yaml provider: name: cloudflare env: - name: CF_API_KEY valueFrom: secretKeyRef: name: cloudflare-api-key key: apiKey - name: CF_API_EMAIL valueFrom: secretKeyRef: name: cloudflare-api-key key: email ``` Use this in your values.yaml, if you are using API Token: ```yaml provider: name: cloudflare env: - name: CF_API_TOKEN valueFrom: secretKeyRef: name: cloudflare-api-key key: apiKey ``` Finally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file: ```shell helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/ ``` ```shell helm repo update ``` ```shell helm upgrade --install external-dns external-dns/external-dns --values values.yaml ``` ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --zone-id-filter=023e105f4ecef8ad9ca31a8372d0c353 # (optional) limit to a specific zone. - --provider=cloudflare - --cloudflare-proxied # (optional) enable the proxy feature of Cloudflare (DDOS protection, CDN...) - --cloudflare-dns-records-per-page=5000 # (optional) configure how many DNS records to fetch per request - --cloudflare-regional-services # (optional) enable the regional hostname feature that configure which region can decrypt HTTPS requests - --cloudflare-region-key="eu" # (optional) configure which region can decrypt HTTPS requests - --cloudflare-record-comment="provisioned by external-dns" # (optional) configure comments for provisioned records; <=100 chars for free zones; <=500 chars for paid zones env: - name: CF_API_KEY valueFrom: secretKeyRef: name: cloudflare-api-key key: apiKey - name: CF_API_EMAIL valueFrom: secretKeyRef: name: cloudflare-api-key key: email ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --zone-id-filter=023e105f4ecef8ad9ca31a8372d0c353 # (optional) limit to a specific zone. - --provider=cloudflare - --cloudflare-proxied # (optional) enable the proxy feature of Cloudflare (DDOS protection, CDN...) - --cloudflare-dns-records-per-page=5000 # (optional) configure how many DNS records to fetch per request - --cloudflare-regional-services # (optional) enable the regional hostname feature that configure which region can decrypt HTTPS requests - --cloudflare-region-key="eu" # (optional) configure which region can decrypt HTTPS requests - --cloudflare-record-comment="provisioned by external-dns" # (optional) configure comments for provisioned records; <=100 chars for free zones; <=500 chars for paid zones env: - name: CF_API_KEY valueFrom: secretKeyRef: name: cloudflare-api-key key: apiKey - name: CF_API_EMAIL valueFrom: secretKeyRef: name: cloudflare-api-key key: email ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: example.com external-dns.alpha.kubernetes.io/ttl: "120" #optional spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Note the annotation on the service; use the same hostname as the Cloudflare DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). By setting the TTL annotation on the service, you have to pass a valid TTL, which must be 120 or above. This annotation is optional, if you won't set it, it will be 1 (automatic) which is 300. For Cloudflare proxied entries, set the TTL annotation to 1 (automatic), or do not set it. ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. Create the deployment and service: ```shell kubectl create -f nginx.yaml ``` Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Cloudflare DNS records. ## Verifying Cloudflare DNS records Check your [Cloudflare dashboard](https://www.cloudflare.com/a/dns/example.com) to view the records for your Cloudflare DNS zone. Substitute the zone for the one created above if a different domain was used. This should show the external IP address of the service as the A record for your domain. ## Cleanup Now that we have verified that ExternalDNS will automatically manage Cloudflare DNS records, we can delete the tutorial's example: ```shell kubectl delete -f nginx.yaml kubectl delete -f externaldns.yaml ``` ## Setting cloudflare-proxied on a per-ingress basis Using the `external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"` annotation on your ingress, you can specify if the proxy feature of Cloudflare should be enabled for that record. This setting will override the global `--cloudflare-proxied` setting. ## Setting cloudflare regional services With Cloudflare regional services you can restrict which data centers can decrypt and serve HTTPS traffic. Configuration of Cloudflare Regional Services is enabled by the `--cloudflare-regional-services` flag. A default region can be defined using the `--cloudflare-region-key` flag. Using the `external-dns.alpha.kubernetes.io/cloudflare-region-key` annotation on your ingress, you can specify the region for that record. An empty string will result in no regional hostname configured. **Accepted values for region key include:** - `eu`: European Union data centers only - `us`: United States data centers only - `ap`: Asia-Pacific data centers only - `fedramp`: US public sector (FedRAMP) data centers - `in`: India data centers only - `ca`: Canada data centers only - `jp`: Japan data centers only - `kr`: South Korea data centers only - `br`: Brazil data centers only - `za`: South Africa data centers only - `ae`: United Arab Emirates data centers only For the most up-to-date list and details, see the [Cloudflare Regional Services documentation](https://developers.cloudflare.com/data-localization/regional-services/get-started/). Currently, requires SuperAdmin or Admin role. ## Setting cloudflare-custom-hostname Automatic configuration of Cloudflare custom hostnames (using A/CNAME DNS records as custom origin servers) is enabled by the `--cloudflare-custom-hostnames` flag and the `external-dns.alpha.kubernetes.io/cloudflare-custom-hostname: ` annotation. Multiple hostnames are supported via a comma-separated list: `external-dns.alpha.kubernetes.io/cloudflare-custom-hostname: ,`. See [Cloudflare for Platforms](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/) for more information on custom hostnames. This feature is disabled by default and supports the `--cloudflare-custom-hostnames-min-tls-version` and `--cloudflare-custom-hostnames-certificate-authority` flags. `--cloudflare-custom-hostnames-certificate-authority` defaults to `none`, which explicitly means no Certificate Authority (CA) is set when using the Cloudflare API. Specifying a custom CA is only possible for enterprise accounts. The custom hostname DNS must resolve to the Cloudflare DNS record (`external-dns.alpha.kubernetes.io/hostname`) for automatic certificate validation via the HTTP method. It's important to note that the TXT method does not allow automatic validation and is not supported. Requires [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) product and "SSL and Certificates" API permission. ## Setting Cloudflare DNS Record Tags Cloudflare allows you to add descriptive tags to DNS records. This can be useful for organizing your records. For example one can apply tags by environment (`production`, `staging`) or by the team that owns them (`frontend-team`, `backend-team`). ExternalDNS can manage these tags for you. To assign tags to a DNS record, add the `external-dns.alpha.kubernetes.io/cloudflare-tags` annotation to your Kubernetes resource (like a Service or Ingress). The value should be a comma-separated list of your desired tags. ```yaml metadata: annotations: # Assigns three tags to the DNS record created from this resource external-dns.alpha.kubernetes.io/cloudflare-tags: "owner:frontend-team, env:dev, component:api" ``` ## Using CRD source to manage DNS records in Cloudflare Please refer to the [CRD source documentation](../sources/crd.md#example) for more information. ================================================ FILE: docs/tutorials/contour.md ================================================ # Contour HTTPProxy This tutorial describes how to configure External DNS to use the Contour `HTTPProxy` source. Using the `HTTPProxy` resource with External DNS requires Contour version 1.5 or greater. ## Example manifests for External DNS ### Without RBAC ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --source=contour-httpproxy - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier ``` ### With RBAC ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] - apiGroups: ["projectcontour.io"] resources: ["httpproxies"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --source=contour-httpproxy - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier ``` ### Verify External DNS works The following instructions are based on the [Contour example workload](https://github.com/projectcontour/contour/tree/master/examples/example-workload/httpproxy). ### Install a sample service ```bash $ kubectl apply -f - <`. ## Prerequisite Before you start, ensure you have: - A running kubernetes cluster. - In this tutorial we are going to use [kind](https://kind.sigs.k8s.io/) - [`kubectl`](https://kubernetes.io/docs/tasks/tools/) and [`helm`](https://helm.sh/) - `external-dns` source code or [helm chart](https://github.com/kubernetes-sigs/external-dns/tree/master/charts/external-dns) - `CoreDNS` [helm chart](https://github.com/coredns/helm) - Optional - `dnstools` container for testing - `etcdctl` to interat with [etcd](https://etcd.io/docs/v3.4/dev-guide/interacting_v3/) ## Bootstrap Environment ### 1. Create cluster ```sh kind create cluster --config=docs/snippets/tutorials/coredns/kind.yaml Creating cluster "coredns-etcd" ... ✓ Ensuring node image (kindest/node:v1.33.0) 🖼 ✓ Preparing nodes 📦 📦 ✓ Writing configuration 📜 ✓ Starting control-plane 🕹️ ✓ Installing CNI 🔌 ✓ Installing StorageClass 💾 ✓ Joining worker nodes 🚜 Set kubectl context to "kind-coredns-etcd" You can now use your cluster with: kubectl cluster-info --context kind-coredns-etcd ``` ### 2. Deploy etcd as stateful set There are multiple options to configure etcd 1. With custom manifest. 2. ETCD [manifest](https://etcd.io/docs/v3.6/op-guide/kubernetes/) 3. ETCD [operator](https://github.com/etcd-io/etcd-operator) In this tutorial, we'll use the first option. ```sh # apply custom manifest from external-dns repository kubectl apply -f docs/snippets/tutorials/coredns/etcd.yaml # wait until it's ready kubectl rollout status statefulset etcd ❯❯ partitioned roll out complete: 1 new pods have been updated... ``` Test etcd connectivity: ```sh kubectl exec -it etcd-0 -- etcdctl member list -wtable +------------------+---------+--------+------------------------+-------------------------+------------+ | ID | STATUS | NAME | PEER ADDRS | CLIENT ADDRS | IS LEARNER | +------------------+---------+--------+------------------------+-------------------------+------------+ | 3b3ae05f90cfc535 | started | etcd-0 | http://10.244.1.3:2380 | http://etcd-0.etcd:2379 | false | +------------------+---------+--------+------------------------+-------------------------+------------+ ``` Test etcd record management: ```sh kubectl -n default exec -it etcd-0 -- etcdctl put /skydns/org/example/myservice '{"host":"10.0.0.10"}' ❯❯ OK kubectl -n default exec -it etcd-0 -- etcdctl get /skydns --prefix ❯❯ /skydns/org/example/myservice ❯❯ {"host":"10.0.0.10"} kubectl -n default exec -it etcd-0 -- etcdctl del /skydns/org/example/myservice ❯❯ 1 ``` To access etcd from host: ```sh etcdctl --endpoints=http://127.0.0.1:32379 member list ❯❯ 3b3ae05f90cfc535, started, etcd-0, http://10.244.1.3:2380, http://etcd-0.etcd:2379, false ``` ### 3. Deploy CoreDNS using Helm - [CoreDNS](https://github.com/coredns/coredns) - [CoreDNS helm](https://github.com/coredns/helm) ```sh helm repo add coredns https://coredns.github.io/helm helm repo update helm upgrade --install coredns coredns/coredns \ -f docs/snippets/tutorials/coredns/values-coredns.yaml \ -n default ❯❯ Release "coredns" does not exist. Installing it now. ``` Validate it's running ```sh kubectl get pods -l app.kubernetes.io/name=coredns ``` Check the logs for errors ```sh kubectl logs deploy/coredns -n default -c coredns --tail=50 kubectl logs deploy/coredns -n default -c resolv-check --tail=50 ``` Test DNS Resolution ```sh kubectl run -it --rm dnsutils --image=infoblox/dnstools ❯❯ curl -v http://etcd.default.svc.cluster.local:2379/version ❯❯ dig @coredns.default.svc.cluster.local kubernetes.default.svc.cluster.local ❯❯ dig @coredns.default.svc.cluster.local etcd.default.svc.cluster.local ``` ### 3. Configure ExternalDNS Deploy with helm and minimal configuration. Add the `external-dns` helm repository and check available versions ```sh helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/ helm repo update helm search repo external-dns --versions ``` Install with required configuration ```sh helm upgrade --install external-dns external-dns/external-dns \ -f docs/snippets/tutorials/coredns/values-extdns-coredns.yaml \ -n default ❯❯ Release "external-dns" does not exist. Installing it now. ``` Validate pod status and view logs ```sh kubectl get pods -l app.kubernetes.io/name=external-dns kubectl logs deploy/external-dns ``` Or run it on the host from sources ```sh export ETCD_URLS="http://127.0.0.1:32379" # port mapping configured on kind cluster go run main.go \ --provider=coredns \ --source=service \ --log-level=debug ``` ### 3. Configure Test Services Apply manifest ```sh kubectl apply -f docs/snippets/tutorials/coredns/fixtures.yaml kubectl get svc -l svc=test-svc ❯❯ NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE ❯❯ a-g1-record LoadBalancer 10.96.233.133 80:31188/TCP 3m38s ❯❯ aa-g1-record LoadBalancer 10.96.93.4 80:31710/TCP 3m38s ``` Patch services, to manually assign an Ingress IPs. It just makes the Service appear like a real LoadBalancer for tools/tests. ```sh kubectl patch svc a-g1-record --type=merge \ -p '{"status":{"loadBalancer":{"ingress":[{"ip":"172.18.0.2"}]}}}' \ --subresource=status ❯❯ service/a-g1-record patched kubectl patch svc aa-g1-record --type=merge \ -p '{"status":{"loadBalancer":{"ingress":[{"ip":"2001:db8::1"}]}}}' \ --subresource=status ❯❯ service/aa-g1-record patched kubectl get svc -l svc=test-svc ❯❯ NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE ❯❯ a-g1-record LoadBalancer 10.96.233.133 172.18.0.2 80:31188/TCP 7m13s ❯❯ aa-g1-record LoadBalancer 10.96.93.4 2001:db8::1 80:31710/TCP 7m13s ``` ### 4. Verify that records are written to etcd Check `etcd` content. Where you should see keys similar to: ```sh kubectl exec -it etcd-0 -- etcdctl get /skydns/org/example --prefix --keys-only ❯❯ /skydns/org/example/a-a/1acbad7e ❯❯ /skydns/org/example/a/048b0377 ❯❯ /skydns/org/example/aa/2b981607 ❯❯ /skydns/org/example/aaaa-aa/1228708f ``` ### 5. Test DNS resolution via CoreDNS Launch a debug pod: ```sh kubectl run --rm -it dnsutils --image=infoblox/dnstools --restart=Never ``` Run with expected output ```sh dig +short @coredns.default.svc.cluster.local a.example.org ❯❯ 172.18.0.2 dig +short @coredns.default.svc.cluster.local aa.example.org AAAA ❯❯ 2001:db8::1 ``` ### 6. Cleanup ```sh kind delete cluster --name coredns-etcd ``` ================================================ FILE: docs/tutorials/coredns.md ================================================ # CoreDNS - [Documentation](https://coredns.io/) ## Multi cluster support options The CoreDNS provider allows records from different CoreDNS providers to be separated in a single etcd by activating the setting `--coredns-strictly-owned` flag and set `txt-owner-id`. It will prevent any override (update/create/delete) of records by a different owner and prevent loading of records by a different owner. Flow: ```mermaid graph TD subgraph ETCD store--> E(services from Cluster A) store--> F(services from Cluster B) store--> G(services from someone else) end subgraph Cluster A A(external-dns with stictly-owned) end A --> E subgraph Cluster B B(external-dns with stictly-owned) end B --> F store --> CoreDNS ``` This features works directly without any change to CoreDNS. CoreDNS will ignore this field inside the etcd record. ### Other entries inside etcd Service entries in etcd without an `owner` field will be filtered out by the provider if `strictly-owned` is activated. Warning: If you activate `strictly-owned` afterwards, these entries will be ignored as the `owner` field is empty. ### Ways to migrate to a multi cluster setup Ways: 1. Add the correct owner to all services inside etcd by adding the field `owner` to the JSON. 2. Remove all services and allow them to be required again after restarting the provider. (Possible downtime.) ## Specific service annotation options ### Groups Groups can be used to group set of services together. The main use of this is to limit recursion, i.e. don't return all records, but only a subset. Let's say we have a configuration like this: ```yaml [[% include 'tutorials/coredns/coredns-groups.yaml' %]] ``` And we want domain.local to return (127.0.0.1 and 127.0.0.2) and subdom.domain.local to return (127.0.0.3 and 127.0.0.4). For this the two domains, need to be in different groups. What those groups are does not matter, as long as a and b belong to the same group which is different from the group c and d belong to. If a service is found without a group it is always included. ================================================ FILE: docs/tutorials/crd.md ================================================ # Using CRD Source for DNS Records This tutorial describes how to use the CRD source with ExternalDNS to manage DNS records. The CRD source allows you to define your desired DNS records declaratively using `DNSEndpoint` custom resources. ## Default Targets and CRD Targets ExternalDNS has a `--default-targets` flag that can be used to specify a default set of targets for all created DNS records. The behavior of how these default targets interact with targets specified in a `DNSEndpoint` CRD has been refined. ### New Behavior (default) By default, ExternalDNS now has the following behavior: - If a `DNSEndpoint` resource has targets specified in its `spec.endpoints[].targets` field, these targets will be used for the DNS record, **overriding** any targets specified via the `--default-targets` flag. - If a `DNSEndpoint` resource has an **empty** `targets` field, the targets from the `--default-targets` flag will be used. This allows for creating records that point to default load balancers or IPs without explicitly listing them in every `DNSEndpoint` resource. ### Legacy Behavior (`--force-default-targets`) To maintain backward compatibility and support certain migration scenarios, the `--force-default-targets` flag is available. - When `--force-default-targets` is used, ExternalDNS will **always** use the targets from `--default-targets`, regardless of whether the `DNSEndpoint` resource has targets specified or not. This flag allows for a smooth migration path to the new behavior. It allow keeping old CRD resources, allows to start removing targets from one by one resource and then remove the flag. ## Examples Let's look at how this works in practice. Assume ExternalDNS is running with `--default-targets=1.2.3.4`. ### DNSEndpoint with Targets Here is a `DNSEndpoint` with a target specified. ```yaml --- apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: targets namespace: default spec: endpoints: - dnsName: smoke-t.example.com recordTTL: 300 recordType: CNAME targets: - placeholder ``` - **Without `--force-default-targets` (New Behavior):** A CNAME record for `smoke-t.example.com` will be created pointing to `placeholder`. - **With `--force-default-targets` (Legacy Behavior):** A CNAME record for `smoke-t.example.com` will be created pointing to `1.2.3.4`. The `placeholder` target will be ignored. ### DNSEndpoint with Empty/No Targets Here is a `DNSEndpoint` without any targets specified. ```yaml --- apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: no-targets namespace: default spec: endpoints: - dnsName: smoke-nt.example.com recordTTL: 300 recordType: CNAME ``` - **Without `--force-default-targets` (New Behavior):** A CNAME record for `smoke-nt.example.com` will be created pointing to `1.2.3.4`. - **With `--force-default-targets` (Legacy Behavior):** A CNAME record for `smoke-nt.example.com` will be created pointing to `1.2.3.4`. `--force-default-targets` allows migration path to clean CRD resources. ### DNSEndpoint with an SRV record Here's an example of a `DNSEndpoint` with an SRV record: ```yaml --- apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: test-srv namespace: default spec: endpoints: - dnsName: _sip._udp.test.example.com recordTTL: 180 recordType: SRV targets: - 1 50 5060 sip1-n1.test.example.com - 1 50 5060 sip1-n2.test.example.com ``` ### DNSEndpoint with an NAPTR record Here's an example of a `DNSEndpoint` with an NAPTR record: ```yaml --- apiVersion: externaldns.k8s.io/v1alpha1 kind: DNSEndpoint metadata: name: test-naptr namespace: default spec: endpoints: - dnsName: test.example.com recordTTL: 180 recordType: NAPTR targets: - 50 50 "S" "SIPS+D2T" "" _sips._tcp.test.example.com. - 100 50 "S" "SIP+D2U" "" _sip._udp.test.example.com. ``` ================================================ FILE: docs/tutorials/dnsimple.md ================================================ # DNSimple This tutorial describes how to setup ExternalDNS for usage with DNSimple. Make sure to use **>=0.4.6** version of ExternalDNS for this tutorial. ## Create a DNSimple API Access Token A DNSimple API access token can be acquired by following the [provided documentation from DNSimple](https://support.dnsimple.com/articles/api-access-token/) The environment variable `DNSIMPLE_OAUTH` must be set to the generated API token to run ExternalDNS with DNSimple. When the generated DNSimple API access token is a _User token_, as opposed to an _Account token_, the following environment variables must also be set: - `DNSIMPLE_ACCOUNT_ID`: Set this to the account ID which the domains to be managed by ExternalDNS belong to (eg. `1001234`). - `DNSIMPLE_ZONES`: Set this to a comma separated list of DNS zones to be managed by ExternalDNS (eg. `mydomain.com,example.com`). ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone you create in DNSimple. - --provider=dnsimple - --registry=txt env: - name: DNSIMPLE_OAUTH value: "YOUR_DNSIMPLE_API_KEY" - name: DNSIMPLE_ACCOUNT_ID value: "SET THIS IF USING A DNSIMPLE USER ACCESS TOKEN" - name: DNSIMPLE_ZONES value: "SET THIS IF USING A DNSIMPLE USER ACCESS TOKEN" ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone you create in DNSimple. - --provider=dnsimple - --registry=txt env: - name: DNSIMPLE_OAUTH value: "YOUR_DNSIMPLE_API_KEY" - name: DNSIMPLE_ACCOUNT_ID value: "SET THIS IF USING A DNSIMPLE USER ACCESS TOKEN" - name: DNSIMPLE_ZONES value: "SET THIS IF USING A DNSIMPLE USER ACCESS TOKEN" ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: validate-external-dns.example.com spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Note the annotation on the service; use the same hostname as the DNSimple DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. Create the deployment and service: ```sh kubectl create -f nginx.yaml ``` Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Check the status by running `kubectl get services nginx`. If the `EXTERNAL-IP` field shows an address, the service is ready to be accessed externally. Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the DNSimple DNS records. ## Verifying DNSimple DNS records ### Getting your DNSimple Account ID If you do not know your DNSimple account ID it can be acquired using the [whoami](https://developer.dnsimple.com/v2/identity/#whoami) endpoint from the DNSimple Identity API ```sh curl -H "Authorization: Bearer $DNSIMPLE_ACCOUNT_TOKEN" \ -H 'Accept: application/json' \ https://api.dnsimple.com/v2/whoami { "data": { "user": null, "account": { "id": 1, "email": "example-account@example.com", "plan_identifier": "dnsimple-professional", "created_at": "2015-09-18T23:04:37Z", "updated_at": "2016-06-09T20:03:39Z" } } } ``` ### Looking at the DNSimple Dashboard You can view your DNSimple Record Editor at https://dnsimple.com/a/YOUR_ACCOUNT_ID/domains/example.com/records. Ensure you substitute the value `YOUR_ACCOUNT_ID` with the ID of your DNSimple account and `example.com` with the correct domain that you used during validation. ### Using the DNSimple Zone Records API This approach allows for you to use the DNSimple [List records for a zone](https://developer.dnsimple.com/v2/zones/records/#listZoneRecords) endpoint to verify the creation of the A and TXT record. Ensure you substitute the value `YOUR_ACCOUNT_ID` with the ID of your DNSimple account and `example.com` with the correct domain that you used during validation. ```sh curl -H "Authorization: Bearer $DNSIMPLE_ACCOUNT_TOKEN" \ -H 'Accept: application/json' \ 'https://api.dnsimple.com/v2/YOUR_ACCOUNT_ID/zones/example.com/records&name=validate-external-dns' ``` ## Clean up Now that we have verified that ExternalDNS will automatically manage DNSimple DNS records, we can delete the tutorial's example: ```sh kubectl delete -f nginx.yaml kubectl delete -f externaldns.yaml ``` ### Deleting Created Records The created records can be deleted using the record IDs from the verification step and the [Delete a zone record](https://developer.dnsimple.com/v2/zones/records/#deleteZoneRecord) endpoint. ================================================ FILE: docs/tutorials/exoscale.md ================================================ # Exoscale ## Prerequisites Exoscale provider support was added via [this PR](https://github.com/kubernetes-sigs/external-dns/pull/625), thus you need to use external-dns v0.5.5. The Exoscale provider expects that your Exoscale zones, you wish to add records to, already exists and are configured correctly. It does not add, remove or configure new zones in anyway. To do this please refer to the [Exoscale DNS documentation](https://community.exoscale.com/documentation/dns/). Additionally you will have to provide the Exoscale...: * API Key * API Secret * Elastic IP address, to access the workers ## Deployment Deploying external DNS for Exoscale is actually nearly identical to deploying it for other providers. This is what a sample `deployment.yaml` looks like: ```yaml [[% include 'exoscale/extdns.yaml' %]] ``` Optional arguments `--exoscale-apizone` and `--exoscale-apienv` define [Exoscale API Zone](https://community.exoscale.com/documentation/platform/exoscale-datacenter-zones/) (default `ch-gva-2`) and Exoscale API environment (default `api`, can be used to target non-production API server) respectively. ## RBAC If your cluster is RBAC enabled, you also need to setup the following, before you can run external-dns: ```yaml [[% include 'exoscale/rbac.yaml' %]] ``` ## Testing and Verification **Important!**: Remember to change `example.com` with your own domain throughout the following text. Spin up a simple nginx HTTP server with the following spec (`kubectl apply -f`): ```yaml [[% include 'exoscale/how-to-test.yaml' %]] ``` **Important!**: Don't run dig, nslookup or similar immediately (until you've confirmed the record exists). You'll get hit by [negative DNS caching](https://tools.ietf.org/html/rfc2308), which is hard to flush. Wait about 30s-1m (interval for external-dns to kick in), then check Exoscales [portal](https://portal.exoscale.com/dns/example.com)... via-ingress.example.com should appear as a A and TXT record with your Elastic-IP-address. ================================================ FILE: docs/tutorials/externalname.md ================================================ # ExternalName Services This tutorial describes how to setup ExternalDNS for usage in conjunction with an ExternalName service. ## Use cases The main use cases that inspired this feature is the necessity for having a subdomain pointing to an external domain. In this scenario, it makes sense for the subdomain to have a CNAME record pointing to the external domain. ## Setup ### External DNS ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --log-level=debug - --source=service - --source=ingress - --namespace=dev - --domain-filter=example.org. - --provider=aws - --registry=txt - --txt-owner-id=dev.example.org ``` ### ExternalName Service ```yaml kind: Service apiVersion: v1 metadata: name: aws-service annotations: external-dns.alpha.kubernetes.io/hostname: tenant1.example.org,tenant2.example.org spec: type: ExternalName externalName: aws.example.org ``` This will create 2 CNAME records pointing to `aws.example.org`: ```sh tenant1.example.org tenant2.example.org ``` ### ExternalName Service with an IP address If `externalName` is an IP address, External DNS will create A records instead of CNAME. ```yaml kind: Service apiVersion: v1 metadata: name: aws-service annotations: external-dns.alpha.kubernetes.io/hostname: tenant1.example.org,tenant2.example.org spec: type: ExternalName externalName: 111.111.111.111 ``` This will create 2 A records pointing to `111.111.111.111`: ```sh tenant1.example.org tenant2.example.org ``` ================================================ FILE: docs/tutorials/gandi.md ================================================ # Gandi This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Gandi. Make sure to use **>=0.7.7** version of ExternalDNS for this tutorial. ## Creating a Gandi DNS zone (domain) Create a new DNS zone where you want to create your records in. Let's use `example.com` as an example here. Make sure the zone uses ## Creating Gandi Personal Access Token (PAT) Generate a Personal Access Token on [your account](https://admin.gandi.net) (click on "User Settings") with `Manage domain name technical configurations` permission. The environment variable `GANDI_PAT` will be needed to run ExternalDNS with Gandi. You can also set `GANDI_KEY` if you have an old API key. ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: replicas: 1 selector: matchLabels: app: external-dns strategy: type: Recreate template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=gandi env: - name: GANDI_PAT value: "YOUR_GANDI_PAT" ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list","watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: replicas: 1 selector: matchLabels: app: external-dns strategy: type: Recreate template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=gandi env: - name: GANDI_PAT value: "YOUR_GANDI_PAT" ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: replicas: 1 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: my-app.example.com spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Note the annotation on the service; use the same hostname as the Gandi Domain. Make sure that your Domain is configured to use Live-DNS. ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. Create the deployment and service: ```console kubectl create -f nginx.yaml ``` Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Gandi DNS records. ## Verifying Gandi DNS records Check your [Gandi Dashboard](https://admin.gandi.net/domain) to view the records for your Gandi DNS zone. Click on the zone for the one created above if a different domain was used. This should show the external IP address of the service as the A record for your domain. ## Cleanup Now that we have verified that ExternalDNS will automatically manage Gandi DNS records, we can delete the tutorial's example: ```sh kubectl delete service -f nginx.yaml kubectl delete service -f externaldns.yaml ``` ## Additional options If you're using organizations to separate your domains, you can pass the organization's ID in an environment variable called `GANDI_SHARING_ID` to get access to it. ================================================ FILE: docs/tutorials/gke-nginx.md ================================================ # GKE with nginx-ingress-controller This tutorial describes how to setup ExternalDNS for usage within a GKE cluster that doesn't make use of Google's [default ingress controller](https://github.com/kubernetes/ingress-gce) but rather uses [nginx-ingress-controller](https://github.com/kubernetes/ingress-nginx) for that task. ## Set up your environment Setup your environment to work with Google Cloud Platform. Fill in your values as needed, e.g. target project. ```console gcloud config set project "zalando-external-dns-test" gcloud config set compute/region "europe-west1" gcloud config set compute/zone "europe-west1-d" ``` ## GKE Node Scopes The following instructions use instance scopes to provide ExternalDNS with the permissions it needs to manage DNS records. Note that since these permissions are associated with the instance, all pods in the cluster will also have these permissions. As such, this approach is not suitable for anything but testing environments. Create a GKE cluster without using the default ingress controller. ```console $ gcloud container clusters create "external-dns" \ --num-nodes 1 \ --scopes "https://www.googleapis.com/auth/ndev.clouddns.readwrite" ``` Create a DNS zone which will contain the managed DNS records. ```console $ gcloud dns managed-zones create "external-dns-test-gcp-zalan-do" \ --dns-name "external-dns-test.gcp.zalan.do." \ --description "Automatically managed zone by ExternalDNS" ``` Make a note of the nameservers that were assigned to your new zone. ```console $ gcloud dns record-sets list \ --zone "external-dns-test-gcp-zalan-do" \ --name "external-dns-test.gcp.zalan.do." \ --type NS NAME TYPE TTL DATA external-dns-test.gcp.zalan.do. NS 21600 ns-cloud-e1.googledomains.com.,ns-cloud-e2.googledomains.com.,ns-cloud-e3.googledomains.com.,ns-cloud-e4.googledomains.com. ``` In this case it's `ns-cloud-{e1-e4}.googledomains.com.` but your's could slightly differ, e.g. `{a1-a4}`, `{b1-b4}` etc. Tell the parent zone where to find the DNS records for this zone by adding the corresponding NS records there. Assuming the parent zone is "gcp-zalan-do" and the domain is "gcp.zalan.do" and that it's also hosted at Google we would do the following. ```console $ gcloud dns record-sets transaction start --zone "gcp-zalan-do" $ gcloud dns record-sets transaction add ns-cloud-e{1..4}.googledomains.com. \ --name "external-dns-test.gcp.zalan.do." --ttl 300 --type NS --zone "gcp-zalan-do" $ gcloud dns record-sets transaction execute --zone "gcp-zalan-do" ``` Connect your `kubectl` client to the cluster you just created and bind your GCP user to the cluster admin role in Kubernetes. ```console $ gcloud container clusters get-credentials "external-dns" $ kubectl create clusterrolebinding cluster-admin-me \ --clusterrole=cluster-admin --user="$(gcloud config get-value account)" ``` ### Deploy the nginx ingress controller First, you need to deploy the nginx-based ingress controller. It can be deployed in at least two modes: Leveraging a Layer 4 load balancer in front of the nginx proxies or directly targeting pods with hostPorts on your worker nodes. ExternalDNS doesn't really care and supports both modes. #### Default Backend The nginx controller uses a default backend that it serves when no Ingress rule matches. This is a separate Service that can be picked by you. We'll use the default backend that's used by other ingress controllers for that matter. Apply the following manifests to your cluster to deploy the default backend. ```yaml apiVersion: v1 kind: Service metadata: name: default-http-backend spec: ports: - port: 80 targetPort: 8080 selector: app: default-http-backend --- apiVersion: apps/v1 kind: Deployment metadata: name: default-http-backend spec: selector: matchLabels: app: default-http-backend template: metadata: labels: app: default-http-backend spec: containers: - name: default-http-backend image: gcr.io/google_containers/defaultbackend:1.3 ``` #### Without a separate TCP load balancer By default, the controller will update your Ingress objects with the public IPs of the nodes running your nginx controller instances. You should run multiple instances in case of pod or node failure. The controller will do leader election and will put multiple IPs as targets in your Ingress objects in that case. It could also make sense to run it as a DaemonSet. However, we'll just run a single replica. You have to open the respective ports on all of your worker nodes to allow nginx to receive traffic. ```console gcloud compute firewall-rules create "allow-http" --allow tcp:80 --source-ranges "0.0.0.0/0" --target-tags "gke-external-dns-9488ba14-node" gcloud compute firewall-rules create "allow-https" --allow tcp:443 --source-ranges "0.0.0.0/0" --target-tags "gke-external-dns-9488ba14-node" ``` Change `--target-tags` to the corresponding tags of your nodes. You can find them by describing your instances or by looking at the default firewall rules created by GKE for your cluster. Apply the following manifests to your cluster to deploy the nginx-based ingress controller. Note, how it receives a reference to the default backend's Service and that it listens on hostPorts. (You may have to use `hostNetwork: true` as well.) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx-ingress-controller spec: selector: matchLabels: app: nginx-ingress-controller template: metadata: labels: app: nginx-ingress-controller spec: containers: - name: nginx-ingress-controller image: gcr.io/google_containers/nginx-ingress-controller:0.9.0-beta.3 args: - /nginx-ingress-controller - --default-backend-service=default/default-http-backend env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: POD_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace ports: - containerPort: 80 hostPort: 80 - containerPort: 443 hostPort: 443 ``` #### With a separate TCP load balancer However, you can also have the ingress controller proxied by a Kubernetes Service. This will instruct the controller to populate this Service's external IP as the external IP of the Ingress. This exposes the nginx proxies via a Layer 4 load balancer (`type=LoadBalancer`) which is more reliable than the other method. With that approach, you can run as many nginx proxy instances on your cluster as you like or have them autoscaled. This is the preferred way of running the nginx controller. Apply the following manifests to your cluster. Note, how the controller is receiving an additional flag telling it which Service it should treat as its public endpoint and how it doesn't need hostPorts anymore. Apply the following manifests to run the controller in this mode. ```yaml apiVersion: v1 kind: Service metadata: name: nginx-ingress-controller spec: type: LoadBalancer ports: - name: http port: 80 targetPort: 80 - name: https port: 443 targetPort: 443 selector: app: nginx-ingress-controller --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx-ingress-controller spec: selector: matchLabels: app: nginx-ingress-controller template: metadata: labels: app: nginx-ingress-controller spec: containers: - name: nginx-ingress-controller image: gcr.io/google_containers/nginx-ingress-controller:0.9.0-beta.3 args: - /nginx-ingress-controller - --default-backend-service=default/default-http-backend - --publish-service=default/nginx-ingress-controller env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: POD_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace ports: - containerPort: 80 - containerPort: 443 ``` ### Deploy ExternalDNS Apply the following manifest file to deploy ExternalDNS. ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=ingress - --domain-filter=external-dns-test.gcp.zalan.do - --provider=google - --google-project=zalando-external-dns-test - --registry=txt - --txt-owner-id=my-identifier ``` Use `--dry-run` if you want to be extra careful on the first run. Note, that you will not see any records created when you are running in dry-run mode. You can, however, inspect the logs and watch what would have been done. ### Deploy a sample application Create the following sample application to test that ExternalDNS works. ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx spec: ingressClassName: nginx rules: - host: via-ingress.external-dns-test.gcp.zalan.do http: paths: - path: / backend: service: name: nginx port: number: 80 pathType: Prefix --- apiVersion: v1 kind: Service metadata: name: nginx spec: ports: - port: 80 targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 ``` After roughly two minutes check that a corresponding DNS record for your Ingress was created. ```console $ gcloud dns record-sets list \ --zone "external-dns-test-gcp-zalan-do" \ --name "via-ingress.external-dns-test.gcp.zalan.do." \ --type A NAME TYPE TTL DATA via-ingress.external-dns-test.gcp.zalan.do. A 300 35.187.1.246 ``` Let's check that we can resolve this DNS name as well. ```console dig +short @ns-cloud-e1.googledomains.com. via-ingress.external-dns-test.gcp.zalan.do. 35.187.1.246 ``` Try with `curl` as well. ```console $ curl via-ingress.external-dns-test.gcp.zalan.do Welcome to nginx! ... ... ``` ### Clean up Make sure to delete all Service and Ingress objects before terminating the cluster so all load balancers and DNS entries get cleaned up correctly. ```console kubectl delete service nginx-ingress-controller kubectl delete ingress nginx ``` Give ExternalDNS some time to clean up the DNS records for you. Then delete the managed zone and cluster. ```console gcloud dns managed-zones delete "external-dns-test-gcp-zalan-do" gcloud container clusters delete "external-dns" ``` Also delete the NS records for your removed zone from the parent zone. ```console $ gcloud dns record-sets transaction start --zone "gcp-zalan-do" $ gcloud dns record-sets transaction remove ns-cloud-e{1..4}.googledomains.com. \ --name "external-dns-test.gcp.zalan.do." --ttl 300 --type NS --zone "gcp-zalan-do" $ gcloud dns record-sets transaction execute --zone "gcp-zalan-do" ``` ## GKE with Workload Identity The following instructions use [GKE workload identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) to provide ExternalDNS with the permissions it needs to manage DNS records. Workload identity is the Google-recommended way to provide GKE workloads access to GCP APIs. Create a GKE cluster with workload identity enabled and without the HttpLoadBalancing add-on. ```console $ gcloud container clusters create external-dns \ --workload-metadata-from-node=GKE_METADATA_SERVER \ --identity-namespace=zalando-external-dns-test.svc.id.goog \ --addons=HorizontalPodAutoscaling ``` Create a GCP service account (GSA) for ExternalDNS and save its email address. ```console $ sa_name="Kubernetes external-dns" $ gcloud iam service-accounts create sa-edns --display-name="$sa_name" $ sa_email=$(gcloud iam service-accounts list --format='value(email)' \ --filter="displayName:$sa_name") ``` Bind the ExternalDNS GSA to the DNS admin role. ```console $ gcloud projects add-iam-policy-binding zalando-external-dns-test \ --member="serviceAccount:$sa_email" --role=roles/dns.admin ``` Link the ExternalDNS GSA to the Kubernetes service account (KSA) that external-dns will run under, i.e., the external-dns KSA in the external-dns namespaces. ```console $ gcloud iam service-accounts add-iam-policy-binding "$sa_email" \ --member="serviceAccount:zalando-external-dns-test.svc.id.goog[external-dns/external-dns]" \ --role=roles/iam.workloadIdentityUser ``` Create a DNS zone which will contain the managed DNS records. ```console $ gcloud dns managed-zones create external-dns-test-gcp-zalan-do \ --dns-name=external-dns-test.gcp.zalan.do. \ --description="Automatically managed zone by ExternalDNS" ``` Make a note of the nameservers that were assigned to your new zone. ```console $ gcloud dns record-sets list \ --zone=external-dns-test-gcp-zalan-do \ --name=external-dns-test.gcp.zalan.do. \ --type NS NAME TYPE TTL DATA external-dns-test.gcp.zalan.do. NS 21600 ns-cloud-e1.googledomains.com.,ns-cloud-e2.googledomains.com.,ns-cloud-e3.googledomains.com.,ns-cloud-e4.googledomains.com. ``` In this case it's `ns-cloud-{e1-e4}.googledomains.com.` but your's could slightly differ, e.g. `{a1-a4}`, `{b1-b4}` etc. Tell the parent zone where to find the DNS records for this zone by adding the corresponding NS records there. Assuming the parent zone is "gcp-zalan-do" and the domain is "gcp.zalan.do" and that it's also hosted at Google we would do the following. ```console $ gcloud dns record-sets transaction start --zone=gcp-zalan-do $ gcloud dns record-sets transaction add ns-cloud-e{1..4}.googledomains.com. \ --name=external-dns-test.gcp.zalan.do. --ttl 300 --type NS --zone=gcp-zalan-do $ gcloud dns record-sets transaction execute --zone=gcp-zalan-do ``` Connect your `kubectl` client to the cluster you just created and bind your GCP user to the cluster admin role in Kubernetes. ```console $ gcloud container clusters get-credentials external-dns $ kubectl create clusterrolebinding cluster-admin-me \ --clusterrole=cluster-admin --user="$(gcloud config get-value account)" ``` ### Deploy ingress-nginx Follow the [ingress-nginx GKE installation instructions](https://kubernetes.github.io/ingress-nginx/deploy/#gce-gke) to deploy it to the cluster. ```console $ kubectl apply -f \ https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v0.35.0/deploy/static/provider/cloud/deploy.yaml ``` ### Deploy ExternalDNS Apply the following manifest file to deploy external-dns. ```yaml apiVersion: v1 kind: Namespace metadata: name: external-dns --- apiVersion: v1 kind: ServiceAccount metadata: name: external-dns namespace: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services", "pods"] verbs: ["get", "watch", "list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions", "networking.k8s.io"] resources: ["ingresses"] verbs: ["get", "watch", "list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: external-dns --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns namespace: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - args: - --source=ingress - --domain-filter=external-dns-test.gcp.zalan.do - --provider=google - --google-project=zalando-external-dns-test - --registry=txt - --txt-owner-id=my-identifier image: registry.k8s.io/external-dns/external-dns:v0.20.0 name: external-dns securityContext: fsGroup: 65534 runAsUser: 65534 serviceAccountName: external-dns ``` Then add the proper workload identity annotation to the cert-manager service account. ```bash $ kubectl annotate serviceaccount --namespace=external-dns external-dns \ "iam.gke.io/gcp-service-account=$sa_email" ``` ### Deploy a sample application Create the following sample application to test that ExternalDNS works. ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx spec: ingressClassName: nginx rules: - host: via-ingress.external-dns-test.gcp.zalan.do http: paths: - path: / backend: service: name: nginx port: number: 80 pathType: Prefix --- apiVersion: v1 kind: Service metadata: name: nginx spec: ports: - port: 80 targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 ``` After roughly two minutes check that a corresponding DNS record for your ingress was created. ```console $ gcloud dns record-sets list \ --zone "external-dns-test-gcp-zalan-do" \ --name "via-ingress.external-dns-test.gcp.zalan.do." \ --type A NAME TYPE TTL DATA via-ingress.external-dns-test.gcp.zalan.do. A 300 35.187.1.246 ``` Let's check that we can resolve this DNS name as well. ```console $ dig +short @ns-cloud-e1.googledomains.com. via-ingress.external-dns-test.gcp.zalan.do. 35.187.1.246 ``` Try with `curl` as well. ```console $ curl via-ingress.external-dns-test.gcp.zalan.do Welcome to nginx! ... ... ``` ### Clean up Make sure to delete all service and ingress objects before terminating the cluster so all load balancers and DNS entries get cleaned up correctly. ```console kubectl delete service --namespace=ingress-nginx ingress-nginx-controller kubectl delete ingress nginx ``` Give ExternalDNS some time to clean up the DNS records for you. Then delete the managed zone and cluster. ```console gcloud dns managed-zones delete external-dns-test-gcp-zalan-do gcloud container clusters delete external-dns ``` Also delete the NS records for your removed zone from the parent zone. ```console $ gcloud dns record-sets transaction start --zone gcp-zalan-do $ gcloud dns record-sets transaction remove ns-cloud-e{1..4}.googledomains.com. \ --name=external-dns-test.gcp.zalan.do. --ttl 300 --type NS --zone=gcp-zalan-do $ gcloud dns record-sets transaction execute --zone=gcp-zalan-do ``` ## User Demo How-To Blogs and Examples * Run external-dns on GKE with workload identity. See [Kubernetes, ingress-nginx, cert-manager & external-dns](https://blog.atomist.com/kubernetes-ingress-nginx-cert-manager-external-dns/) ================================================ FILE: docs/tutorials/gke.md ================================================ # GKE with default controller This tutorial describes how to setup ExternalDNS for usage within a [GKE](https://cloud.google.com/kubernetes-engine) ([Google Kuberentes Engine](https://cloud.google.com/kubernetes-engine)) cluster. Make sure to use **>=0.11.0** version of ExternalDNS for this tutorial ## Single project test scenario using access scopes *If you prefer to try-out ExternalDNS in one of the existing environments you can skip this step* The following instructions use [access scopes](https://cloud.google.com/compute/docs/access/service-accounts#accesscopesiam) to provide ExternalDNS with the permissions it needs to manage DNS records within a single [project](https://cloud.google.com/docs/overview#projects), the organizing entity to allocate resources. Note that since these permissions are associated with the instance, all pods in the cluster will also have these permissions. As such, this approach is not suitable for anything but testing environments. This solution will only work when both CloudDNS and GKE are provisioned in the same project. If the CloudDNS zone is in a different project, this solution will not work. ### Configure Project Environment Set up your environment to work with Google Cloud Platform. Fill in your variables as needed, e.g. target project. ```bash # set variables to the appropriate desired values PROJECT_ID="my-external-dns-test" REGION="europe-west1" ZONE="europe-west1-d" ClOUD_BILLING_ACCOUNT="" # set default settings for project gcloud config set project $PROJECT_ID gcloud config set compute/region $REGION gcloud config set compute/zone $ZONE # enable billing and APIs if not done already gcloud beta billing projects link $PROJECT_ID \ --billing-account $BILLING_ACCOUNT gcloud services enable "dns.googleapis.com" gcloud services enable "container.googleapis.com" ``` ### Create GKE Cluster ```bash gcloud container clusters create $GKE_CLUSTER_NAME \ --num-nodes 1 \ --scopes "https://www.googleapis.com/auth/ndev.clouddns.readwrite" ``` > [!WARNING] > Note that this cluster will use the default [compute engine GSA](https://cloud.google.com/compute/docs/access/service-accounts#default_service_account) that contians the overly permissive project editor (`roles/editor`) role. > So essentially, anything on the cluster could potentially grant escalated privileges. > Also, as mentioned earlier, the access scope `ndev.clouddns.readwrite` will allow anything running on the cluster to have read/write permissions on all Cloud DNS zones within the same project. ### Cloud DNS Zone Create a DNS zone which will contain the managed DNS records. If using your own domain that was registered with a third-party domain registrar, you should point your domain's name servers to the values under the `nameServers` key. Please consult your registrar's documentation on how to do that. This tutorial will use example domain of `example.com`. ```bash gcloud dns managed-zones create "example-com" --dns-name "example.com." \ --description "Automatically managed zone by kubernetes.io/external-dns" ``` Make a note of the nameservers that were assigned to your new zone. ```bash gcloud dns record-sets list \ --zone "example-com" --name "example.com." --type NS ``` Outputs: ```sh NAME TYPE TTL DATA example.com. NS 21600 ns-cloud-e1.googledomains.com.,ns-cloud-e2.googledomains.com.,ns-cloud-e3.googledomains.com.,ns-cloud-e4.googledomains.com. ``` In this case it's `ns-cloud-{e1-e4}.googledomains.com.` but your's could slightly differ, e.g. `{a1-a4}`, `{b1-b4}` etc. ## Cross project access scenario using Google Service Account More often, following best practices in regards to security and operations, Cloud DNS zones will be managed in a separate project from the Kubernetes cluster. This section shows how setup ExternalDNS to access Cloud DNS from a different project. These steps will also work for single project scenarios as well. ExternalDNS will need permissions to make changes to the Cloud DNS zone. There are three ways to configure the access needed: * [Worker Node Service Account](#worker-node-service-account-method) * [Static Credentials](#static-credentials) * [Workload Identity](#workload-identity) ### Setup Cloud DNS and GKE Below are examples on how you can configure Cloud DNS and GKE in separate projects, and then use one of the three methods to grant access to ExternalDNS. Replace the environment variables to values that make sense in your environment. #### Configure Projects For this process, create projects with the appropriate APIs enabled. ```bash # set variables to appropriate desired values GKE_PROJECT_ID="my-workload-project" DNS_PROJECT_ID="my-cloud-dns-project" ClOUD_BILLING_ACCOUNT="" # enable billing and APIs for DNS project if not done already gcloud config set project $DNS_PROJECT_ID gcloud beta billing projects link $CLOUD_DNS_PROJECT \ --billing-account $ClOUD_BILLING_ACCOUNT gcloud services enable "dns.googleapis.com" # enable billing and APIs for GKE project if not done already gcloud config set project $GKE_PROJECT_ID gcloud beta billing projects link $CLOUD_DNS_PROJECT \ --billing-account $ClOUD_BILLING_ACCOUNT gcloud services enable "container.googleapis.com" ``` #### Provisioning Cloud DNS Create a Cloud DNS zone in the designated DNS project. ```bash gcloud dns managed-zones create "example-com" --project $DNS_PROJECT_ID \ --description "example.com" --dns-name="example.com." --visibility=public ``` If using your own domain that was registered with a third-party domain registrar, you should point your domain's name servers to the values under the `nameServers` key. Please consult your registrar's documentation on how to do that. The example domain of `example.com` will be used for this tutorial. #### Provisioning a GKE cluster for cross project access Create a GSA (Google Service Account) and grant it the [minimal set of privileges required](https://cloud.google.com/kubernetes-engine/docs/how-to/hardening-your-cluster#use_least_privilege_sa) for GKE nodes: ```bash GKE_CLUSTER_NAME="my-external-dns-cluster" GKE_REGION="us-central1" GKE_SA_NAME="worker-nodes-sa" GKE_SA_EMAIL="$GKE_SA_NAME@${GKE_PROJECT_ID}.iam.gserviceaccount.com" ROLES=( roles/logging.logWriter roles/monitoring.metricWriter roles/monitoring.viewer roles/stackdriver.resourceMetadata.writer ) gcloud iam service-accounts create $GKE_SA_NAME \ --display-name $GKE_SA_NAME --project $GKE_PROJECT_ID # assign google service account to roles in GKE project for ROLE in ${ROLES[*]}; do gcloud projects add-iam-policy-binding $GKE_PROJECT_ID \ --member "serviceAccount:$GKE_SA_EMAIL" \ --role $ROLE done ``` Create a cluster using this service account and enable [workload identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity): ```bash gcloud container clusters create $GKE_CLUSTER_NAME \ --project $GKE_PROJECT_ID --region $GKE_REGION --num-nodes 1 \ --service-account "$GKE_SA_EMAIL" \ --workload-pool "$GKE_PROJECT_ID.svc.id.goog" ``` ### Workload Identity [Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) allows workloads in your GKE cluster to [authenticate directly to GCP](https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity#credential-flow) using Kubernetes Service Accounts You have an option to chose from using the gcloud CLI or using Terraform. === "gcloud CLI" The below instructions assume you are using the default Kubernetes Service account name of `external-dns` in the namespace `external-dns` Grant the Kubernetes service account DNS `roles/dns.admin` at project level ```shell gcloud projects add-iam-policy-binding projects/DNS_PROJECT_ID \ --role=roles/dns.admin \ --member=principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/PROJECT_ID.svc.id.goog/subject/ns/external-dns/sa/external-dns \ --condition=None ``` Replace the following: * `DNS_PROJECT_ID` : Project ID of your DNS project. If DNS is in the same project as your GKE cluster, use your GKE project. * `PROJECT_ID`: your Google Cloud project ID of your GKE Cluster * `PROJECT_NUMBER`: your numerical Google Cloud project number of your GKE cluster If you wish to change the namespace, replace * `ns/external-dns` with `ns/` === "Terraform" The below instructions assume you are using the default Kubernetes Service account name of `external-dns` in the namespace `external-dns` Create a file called `main.tf` and place in it the below. _Note: If you're an experienced terraform user feel free to split these out in to different files_ ```hcl variable "gke-project" { type = string description = "Name of the project that the GKE cluster exists in" default = "GKE-PROJECT" } variable "ksa_name" { type = string description = "Name of the Kubernetes service account that will be accessing the DNS Zones" default = "external-dns" } variable "kns_name" { type = string description = "Name of the Kubernetes Namespace" default = "external-dns" } data "google_project" "project" { project_id = var.gke-project } locals { member = "principal://iam.googleapis.com/projects/${data.google_project.project.number}/locations/global/workloadIdentityPools/${var.gke-project}.svc.id.goog/subject/ns/${var.kns_name}/sa/${var.ksa_name}" } resource "google_project_iam_member" "external_dns" { member = local.member project = "DNS-PROJECT" role = "roles/dns.reader" } resource "google_dns_managed_zone_iam_member" "member" { project = "DNS-PROJECT" managed_zone = "ZONE-NAME" role = "roles/dns.admin" member = local.member } ``` Replace the following * `GKE-PROJECT` : Project that contains your GKE cluster * `DNS-PROJECT` : Project that holds your DNS zones You can also change the below if you plan to use a different service account name and namespace * `variable "ksa_name"` : Name of the Kubernetes service account external-dns will use * `variable "kns_name"` : Name of the Kubernetes Name Space that will have external-dns installed to ### Worker Node Service Account method In this method, the GSA (Google Service Account) that is associated with GKE worker nodes will be configured to have access to Cloud DNS. **WARNING**: This will grant access to modify the Cloud DNS zone records for all containers running on cluster, not just ExternalDNS, so use this option with caution. This is not recommended for production environments. ```bash GKE_SA_EMAIL="$GKE_SA_NAME@${GKE_PROJECT_ID}.iam.gserviceaccount.com" # assign google service account to dns.admin role in the cloud dns project gcloud projects add-iam-policy-binding $DNS_PROJECT_ID \ --member serviceAccount:$GKE_SA_EMAIL \ --role roles/dns.admin ``` After this, follow the steps in [Deploy ExternalDNS](#deploy-externaldns). Make sure to set the `--google-project` flag to match the Cloud DNS project name. ### Static Credentials In this scenario, a new GSA (Google Service Account) is created that has access to the CloudDNS zone. The credentials for this GSA are saved and installed as a Kubernetes secret that will be used by ExternalDNS. This allows only containers that have access to the secret, such as ExternalDNS to update records on the Cloud DNS Zone. #### Create GSA for use with static credentials ```bash DNS_SA_NAME="external-dns-sa" DNS_SA_EMAIL="$DNS_SA_NAME@${GKE_PROJECT_ID}.iam.gserviceaccount.com" # create GSA used to access the Cloud DNS zone gcloud iam service-accounts create $DNS_SA_NAME --display-name $DNS_SA_NAME # assign google service account to dns.admin role in cloud-dns project gcloud projects add-iam-policy-binding $DNS_PROJECT_ID \ --member serviceAccount:$DNS_SA_EMAIL --role "roles/dns.admin" ``` #### Create Kubernetes secret using static credentials Generate static credentials from the ExternalDNS GSA. ```bash # download static credentials gcloud iam service-accounts keys create /local/path/to/credentials.json \ --iam-account $DNS_SA_EMAIL ``` Create a Kubernetes secret with the credentials in the same namespace of ExternalDNS. ```bash kubectl create secret generic "external-dns" --namespace ${EXTERNALDNS_NS:-"default"} \ --from-file /local/path/to/credentials.json ``` After this, follow the steps in [Deploy ExternalDNS](#deploy-externaldns). Make sure to set the `--google-project` flag to match Cloud DNS project name. Make sure to uncomment out the section that mounts the secret to the ExternalDNS pods. #### Deploy External DNS Deploy ExternalDNS with the following steps below, documented under [Deploy ExternalDNS](#deploy-externaldns). Set the `--google-project` flag to the Cloud DNS project name. #### Update ExternalDNS pods !!! note "Only required if not enabled on all nodes" If you have GKE Workload Identity enabled on all nodes in your cluster, the below step is not necessary Update the Pod spec to schedule the workloads on nodes that use Workload Identity and to use the annotated Kubernetes service account. ```bash kubectl patch deployment "external-dns" \ --namespace ${EXTERNALDNS_NS:-"default"} \ --patch \ '{"spec": {"template": {"spec": {"nodeSelector": {"iam.gke.io/gke-metadata-server-enabled": "true"}}}}}' ``` After all of these steps you may see several messages with `googleapi: Error 403: Forbidden, forbidden`. After several minutes when the token is refreshed, these error messages will go away, and you should see info messages, such as: `All records are already up to date`. ## Deploy ExternalDNS Then apply the following manifests file to deploy ExternalDNS. ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns labels: app.kubernetes.io/name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns labels: app.kubernetes.io/name: external-dns rules: - apiGroups: [""] resources: ["services","pods","nodes"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer labels: app.kubernetes.io/name: external-dns roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default # change if namespace is not 'default' --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns labels: app.kubernetes.io/name: external-dns spec: strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: external-dns template: metadata: labels: app.kubernetes.io/name: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --domain-filter=example.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=google - --log-format=json # google cloud logs parses severity of the "text" log format incorrectly # - --google-project=my-cloud-dns-project # Use this to specify a project different from the one external-dns is running inside - --google-zone-visibility=public # Use this to filter to only zones with this visibility. Set to either 'public' or 'private'. Omitting will match public and private zones - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --registry=txt - --txt-owner-id=my-identifier # # uncomment below if static credentials are used # env: # - name: GOOGLE_APPLICATION_CREDENTIALS # value: /etc/secrets/service-account/credentials.json # volumeMounts: # - name: google-service-account # mountPath: /etc/secrets/service-account/ # volumes: # - name: google-service-account # secret: # secretName: external-dns ``` Create the deployment for ExternalDNS: ```bash kubectl create --namespace "default" --filename externaldns.yaml ``` ## Verify ExternalDNS works The following will deploy a small nginx server that will be used to demonstrate that ExternalDNS is working. ### Verify using an external load balancer Create the following sample application to test that ExternalDNS works. This example will provision a L4 load balancer. ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: # change nginx.example.com to match an appropriate value external-dns.alpha.kubernetes.io/hostname: nginx.example.com spec: type: LoadBalancer ports: - port: 80 targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 ``` Create the deployment and service objects: ```bash kubectl create --namespace "default" --filename nginx.yaml ``` After roughly two minutes check that a corresponding DNS record for your service was created. ```bash gcloud dns record-sets list --zone "example-com" --name "nginx.example.com." ``` Example output: ```sh NAME TYPE TTL DATA nginx.example.com. A 300 104.155.60.49 nginx.example.com. TXT 300 "heritage=external-dns,external-dns/owner=my-identifier" ``` Note created `TXT` record alongside `A` record. `TXT` record signifies that the corresponding `A` record is managed by ExternalDNS. This makes ExternalDNS safe for running in environments where there are other records managed via other means. Let's check that we can resolve this DNS name. We'll ask the nameservers assigned to your zone first. ```bash dig +short @ns-cloud-e1.googledomains.com. nginx.example.com. 104.155.60.49 ``` Given you hooked up your DNS zone with its parent zone you can use `curl` to access your site. ```bash curl nginx.example.com ``` ### Verify using an ingress Let's check that Ingress works as well. Create the following Ingress. ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: nginx spec: rules: - host: server.example.com http: paths: - path: / pathType: Prefix backend: service: name: nginx port: number: 80 ``` Create the ingress objects with: ```bash kubectl create --namespace "default" --filename ingress.yaml ``` Note that this will ingress object will use the default ingress controller that comes with GKE to create a L7 load balancer in addition to the L4 load balancer previously with the service object. To use only the L7 load balancer, update the service manafest to change the Service type to `NodePort` and remove the ExternalDNS annotation. After roughly two minutes check that a corresponding DNS record for your Ingress was created. ```bash gcloud dns record-sets list \ --zone "example-com" \ --name "server.example.com." \ ``` Output: ```sh NAME TYPE TTL DATA server.example.com. A 300 130.211.46.224 server.example.com. TXT 300 "heritage=external-dns,external-dns/owner=my-identifier" ``` Let's check that we can resolve this DNS name as well. ```bash dig +short @ns-cloud-e1.googledomains.com. server.example.com. 130.211.46.224 ``` Try with `curl` as well. ```bash curl server.example.com ``` ### Clean up Make sure to delete all Service and Ingress objects before terminating the cluster so all load balancers get cleaned up correctly. ```bash kubectl delete service nginx kubectl delete ingress nginx ``` Give ExternalDNS some time to clean up the DNS records for you. Then delete the managed zone and cluster. ```bash gcloud dns managed-zones delete "example-com" gcloud container clusters delete "external-dns" ``` ================================================ FILE: docs/tutorials/godaddy.md ================================================ # GoDaddy This tutorial describes how to set up ExternalDNS for use within a Kubernetes cluster using GoDaddy DNS. Make sure to use **>=0.6** version of ExternalDNS for this tutorial. ## Creating a zone with GoDaddy DNS If you are new to GoDaddy, we recommend you first read the following instructions for creating a zone. [Creating a zone using the GoDaddy web console](https://www.godaddy.com/) [Creating a zone using the GoDaddy API](https://developer.godaddy.com/) ## Creating GoDaddy API key You first need to create an API Key. Using the [GoDaddy documentation](https://developer.godaddy.com/getstarted) you will have your `API key` and `API secret` ## Deploy ExternalDNS Connect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment: ## Using Helm Create a values.yaml file to configure ExternalDNS to use GoDaddy as the DNS provider. This file should include the necessary environment variables: ```shell provider: name: godaddy extraArgs: - --godaddy-api-key=YOUR_API_KEY - --godaddy-api-secret=YOUR_API_SECRET ``` Be sure to replace YOUR_API_KEY and YOUR_API_SECRET with your actual GoDaddy API key and GoDaddy API secret. Finally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file: ```shell helm upgrade --install external-dns external-dns/external-dns --values values.yaml ``` ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=godaddy - --txt-prefix=external-dns. # In case of multiple k8s cluster - --txt-owner-id=owner-id # In case of multiple k8s cluster - --godaddy-api-key= - --godaddy-api-secret= ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list","watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=godaddy - --txt-prefix=external-dns. # In case of multiple k8s cluster - --txt-owner-id=owner-id # In case of multiple k8s cluster - --godaddy-api-key= - --godaddy-api-secret= ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: example.com external-dns.alpha.kubernetes.io/ttl: "120" #optional spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` **A note about annotations** Verify that the annotation on the service uses the same hostname as the GoDaddy DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). The TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10. ExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records. ### Create the deployment and service ```sh kubectl create -f nginx.yaml ``` Depending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the GoDaddy DNS records. ## Verifying GoDaddy DNS records Use the GoDaddy web console or API to verify that the A record for your domain shows the external IP address of the services. ## Cleanup Once you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example: ```sh kubectl delete -f nginx.yaml kubectl delete -f externaldns.yaml ``` ================================================ FILE: docs/tutorials/hostport.md ================================================ # Headless Services This tutorial describes how to setup ExternalDNS for usage in conjunction with a Headless service. ## Use cases The main use cases that inspired this feature is the necessity for fixed addressable hostnames with services, such as Kafka when trying to access them from outside the cluster. In this scenario, quite often, only the Node IP addresses are actually routable and as in systems like Kafka more direct connections are preferable. ## Setup We will go through a small example of deploying a simple Kafka with use of a headless service. ### External DNS A simple deploy could look like this: ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --log-level=debug - --source=service - --source=ingress - --namespace=dev - --domain-filter=example.org. - --provider=aws - --registry=txt - --txt-owner-id=dev.example.org ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --log-level=debug - --source=service - --source=ingress - --namespace=dev - --domain-filter=example.org. - --provider=aws - --registry=txt - --txt-owner-id=dev.example.org ``` ### Kafka Stateful Set First lets deploy a Kafka Stateful set, a simple example(a lot of stuff is missing) with a headless service called `ksvc` ```yaml apiVersion: apps/v1 kind: StatefulSet metadata: name: kafka spec: serviceName: ksvc replicas: 3 template: metadata: labels: component: kafka spec: containers: - name: kafka image: confluent/kafka ports: - containerPort: 9092 hostPort: 9092 name: external command: - bash - -c - " export DOMAIN=$(hostname -d) && \ export KAFKA_BROKER_ID=$(echo $HOSTNAME|rev|cut -d '-' -f 1|rev) && \ export KAFKA_ZOOKEEPER_CONNECT=$ZK_CSVC_SERVICE_HOST:$ZK_CSVC_SERVICE_PORT && \ export KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://$HOSTNAME.example.org:9092 && \ /etc/confluent/docker/run" volumeMounts: - name: datadir mountPath: /var/lib/kafka volumeClaimTemplates: - metadata: name: datadir annotations: volume.beta.kubernetes.io/storage-class: st1 spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 500Gi ``` Very important here, is to set the `hostPort`(only works if the PodSecurityPolicy allows it)! and in case your app requires an actual hostname inside the container, unlike Kafka, which can advertise on another address, you have to set the hostname yourself. ### Headless Service Now we need to define a headless service to use to expose the Kafka pods. There are generally two approaches to use expose the nodeport of a Headless service: 1. Add `--fqdn-template={{ .Name }}.example.org` 2. Use a full annotation If you go with #1, you just need to define the headless service, here is an example of the case #2: ```yaml apiVersion: v1 kind: Service metadata: name: ksvc annotations: external-dns.alpha.kubernetes.io/hostname: example.org spec: ports: - port: 9092 name: external clusterIP: None selector: component: kafka ``` This will create 4 dns records: ```sh kafka-0.example.org IP-0 kafka-1.example.org IP-1 kafka-2.example.org IP-2 example.org IP-0,IP-1,IP-2 ``` > !Notice rood domain with records `example.org` If you set `--fqdn-template={{ .Name }}.example.org` you can omit the annotation. ```sh kafka-0.ksvc.example.org IP-0 kafka-1.ksvc.example.org IP-1 kafka-2.ksvc.example.org IP-2 ksvc.example.org IP-0,IP-1,IP-2 ``` #### Using pods' HostIPs as targets Add the following annotation to your `Service`: ```yaml external-dns.alpha.kubernetes.io/endpoints-type: HostIP ``` external-dns will now publish the value of the `.status.hostIP` field of the pods backing your `Service`. #### Using node external IPs as targets Add the following annotation to your `Service`: ```yaml external-dns.alpha.kubernetes.io/endpoints-type: NodeExternalIP ``` external-dns will now publish the node external IP (`.status.addresses` entries of with `type: NodeExternalIP`) of the nodes on which the pods backing your `Service` are running. #### Using pod annotations to specify target IPs Add the following annotation to the **pods** backing your `Service`: ```yaml external-dns.alpha.kubernetes.io/target: "1.2.3.4" ``` external-dns will publish the IP specified in the annotation of each pod instead of using the podIP advertised by Kubernetes. This can be useful e.g. if you are NATing public IPs onto your pod IPs and want to publish these in DNS. ================================================ FILE: docs/tutorials/ionoscloud.md ================================================ # IONOS Cloud This tutorial describes how to set up ExternalDNS for use within a Kubernetes cluster using IONOS Cloud DNS. For more details, visit the [IONOS external-dns webhook repository](https://github.com/ionos-cloud/external-dns-ionos-webhook). You can also find the [external-dns-ionos-webhook container image](https://github.com/ionos-cloud/external-dns-ionos-webhook/pkgs/container/external-dns-ionos-webhook) required for this setup. ## Creating a DNS Zone with IONOS Cloud DNS If you are new to IONOS Cloud DNS, we recommend you first read the following instructions for creating a DNS zone: - [Manage DNS Zones in Data Centre Designer](https://docs.ionos.com/cloud/network-services/cloud-dns/dcd-how-tos/manage-dns-zone) - [Creating a DNS Zone using the IONOS Cloud DNS API](https://docs.ionos.com/cloud/network-services/cloud-dns/api-how-tos/create-dns-zone) ### Steps to Create a DNS Zone 1. Log in to the [IONOS Cloud Data Center Designer](https://dcd.ionos.com/). 2. Navigate to the **Network Services** section and select **Cloud DNS**. 3. Click on **Create Zone** and provide the following details: - **Zone Name**: Enter the domain name (e.g., `example.com`). - **Description**: It is optional to provide a description of your zone. 4. Save the zone configuration. For more advanced configurations, such as adding records or managing subdomains, refer to the [IONOS Cloud DNS Documentation](https://docs.ionos.com/cloud/network-services/cloud-dns/). ## Creating an IONOS API Token To use ExternalDNS with IONOS Cloud DNS, you need an API token with sufficient privileges to manage DNS zones and records. Follow these steps to create an API token: 1. Log in to the [IONOS Cloud Data Center Designer](https://dcd.ionos.com/). 2. Navigate to the **Management** section in the top right corner and select **Token Manager**. 3. Select the Time To Live(TTL) of the token and click on **Create Token**. 4. Copy the generated token and store it securely. You will use this token to authenticate ExternalDNS. ## Deploy ExternalDNS ### Step 1: Create a Kubernetes Secret for the IONOS API Token Store your IONOS API token securely in a Kubernetes secret: ```bash kubectl create secret generic ionos-credentials --from-literal=api-key='' ``` Replace `` with your actual IONOS API token. ### Step 2: Configure ExternalDNS Create a Helm values file for the ExternalDNS Helm chart that includes the webhook configuration. In this example, the values file is called `external-dns-ionos-values.yaml` . ```yaml logLevel: debug # ExternalDNS Log level, reduce in production namespaced: false # if true, ExternalDNS will run in a namespaced scope (Role and Rolebinding will be namespaced too). triggerLoopOnEvent: true # if true, ExternalDNS will trigger a loop on every event (create/update/delete) on the resources it watches. logLevel: debug sources: - ingress - service provider: name: webhook webhook: image: repository: ghcr.io/ionos-cloud/external-dns-ionos-webhook tag: latest pullPolicy: IfNotPresent env: - name: IONOS_API_KEY valueFrom: secretKeyRef: name: ionos-credentials key: api-key - name: SERVER_PORT value: "8888" - name: METRICS_PORT value: "8080" - name: DRY_RUN value: "false" ``` ### Step 3: Install ExternalDNS Using Helm Install ExternalDNS with the IONOS webhook provider: ```bash helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/ helm upgrade --install external-dns external-dns/external-dns -f external-dns-ionos-values.yaml ``` ## Deploying an Example Application ### Step 1: Create a Deployment In this step we will create `echoserver` application manifest with the following content: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: echoserver namespace: default spec: replicas: 1 selector: matchLabels: app: echoserver template: metadata: labels: app: echoserver spec: containers: - name: echoserver image: ealen/echo-server:latest ports: - containerPort: 80 ``` Deployment manifest can be saved in `echoserver-deployment.yaml` file. Next, we will apply the deployment: ```bash kubectl apply -f echoserver-deployment.yaml ``` ### Step 2: Create a Service In this step, we will create a `Service` manifest to expose the `echoserver` application within the cluster. The service will also include an annotation for ExternalDNS to create a DNS record for the specified hostname. Save the following content in a file named `echoserver-service.yaml`: ```yaml apiVersion: v1 kind: Service metadata: name: echoserver annotations: external-dns.alpha.kubernetes.io/hostname: app.example.com spec: ports: - port: 80 targetPort: 80 selector: app: echoserver ``` **Note:** Replace `app.example.com` with a subdomain of your DNS zone configured in IONOS Cloud DNS. For example, if your DNS zone is `example.com`, you can use a subdomain like `app.example.com`. Next, apply the service: ```bash kubectl apply -f echoserver-service.yaml ``` This service will expose the echoserver application on port 80 and instruct ExternalDNS to create a DNS record for `app.example.com`. ### Step 3: Create an Ingress In this step, we will create an `Ingress` resource to expose the `echoserver` application externally. The ingress will route HTTP traffic to the `echoserver` service and include a hostname that ExternalDNS will use to create the corresponding DNS record. Save the following content in a file named `echoserver-ingress.yaml` : ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: echoserver spec: rules: - host: app.example.com http: paths: - path: / pathType: Prefix backend: service: name: echoserver port: number: 80 ``` **Note:** Replace `app.example.com` with a subdomain of your DNS zone configured in IONOS Cloud DNS. For example, if your DNS zone is `example.com`, you can use a subdomain like `app.example.com`. Next, apply the ingress manifest: ```bash kubectl apply -f echoserver-ingress.yaml ``` This ingress will expose the `echoserver` application at `http://app.example.com` and instruct ExternalDNS to create a DNS record for the specified hostname. ## Accessing the Application Once the `Ingress` resource has been applied and the DNS records have been created, you can access the application using the hostname specified in the ingress (`app.example.com`). ### Verify Application Access Use the following `curl` command to verify that the application is accessible: ```bash curl -I http://app.example.com ``` Replace app.example.com with the subdomain you configured in your DNS zone. **Note:** Ensure that your DNS changes have propagated and that the hostname resolves to the correct IP address before running the command. ### Expected result You should see an HTTP response header indicating that the application is running, such as: ```bash HTTP/1.1 200 OK ``` > **Troubleshooting:** > >If you encounter any issues, verify the following: > > - The DNS record for `app.example.com` (replace with your own subdomain configured in IONOS Cloud DNS) has been created in IONOS Cloud DNS. > - The ingress controller is running and properly configured in your Kubernetes cluster. > - The `echoserver` application is running and accessible within the cluster. ## Verifying IONOS Cloud DNS Records Use the IONOS Cloud Console or API to verify that the A and TXT records for your domain have been created. For example, you can use the following API call: ```bash curl --location --request GET 'https://dns.de-fra.ionos.com/records?filter.name=app' \ --header 'Authorization: Bearer ' ``` Replace `` with your actual API token. The API response should include the `A` and `TXT` records for the subdomain you configured. > **Note:** DNS changes may take a few minutes to propagate. If the records are not visible immediately, wait and try again. ## Cleanup > **Optional:** Perform the cleanup step only if you no longer need the deployed resources. Once you have verified the setup, you can clean up the resources created during this tutorial: ```bash kubectl delete -f echoserver-deployment.yaml kubectl delete -f echoserver-service.yaml kubectl delete -f echoserver-ingress.yaml ``` ## Summary In this tutorial, you successfully deployed ExternalDNS webhook with IONOS Cloud DNS as the provider. You created a Kubernetes deployment, service, and ingress, and verified that DNS records were created and the application was accessible. You also learned how to clean up the resources when they are no longer needed. ================================================ FILE: docs/tutorials/kops-dns-controller.md ================================================ # kOps dns-controller kOps includes a dns-controller that is primarily used to bootstrap the cluster, but can also be used for provisioning DNS entries for Services and Ingress. ExternalDNS can be used as a drop-in replacement for dns-controller if you are running a non-gossip cluster. The flag `--compatibility kops-dns-controller` enables the dns-controller behaviour. ## Annotations In kops-dns-controller compatibility mode, ExternalDNS supports two additional annotations: * `dns.alpha.kubernetes.io/external` which is used to define a DNS record for accessing the resource publicly (i.e. public IPs) * `dns.alpha.kubernetes.io/internal` which is used to define a DNS record for accessing the resource from outside the cluster but inside the cloud, i.e. it will typically use internal IPs for instances. These annotations may both be comma-separated lists of names. ## DNS record mappings The DNS record mappings try to "do the right thing", but what this means is different for each resource type. ### Pods For the external annotation, ExternalDNS will map a Pod to the external IPs of the Node. For the internal annotation, ExternalDNS will map a Pod to the internal IPs of the Node. Annotations added to Pods will always result in an A record being created. ### Services * For a Service of Type=LoadBalancer, ExternalDNS looks at Status.LoadBalancer.Ingress. It will create CNAMEs to hostnames, and A records for IP addresses. It will do this for both internal and external names * For a Service of Type=NodePort, ExternalDNS will create A records for the Node's internal/external IP addresses, as appropriate. ================================================ FILE: docs/tutorials/kube-ingress-aws.md ================================================ # kube-ingress-aws-controller This tutorial describes how to use ExternalDNS with the [kube-ingress-aws-controller][1]. [1]: https://github.com/zalando-incubator/kube-ingress-aws-controller ## Setting up ExternalDNS and kube-ingress-aws-controller Follow the [AWS tutorial](aws.md) to setup ExternalDNS for use in Kubernetes clusters running in AWS. Specify the `source=ingress` argument so that ExternalDNS will look for hostnames in Ingress objects. In addition, you may wish to limit which Ingress objects are used as an ExternalDNS source via the `ingress-class` argument, but this is not required. For help setting up the Kubernetes Ingress AWS Controller, that can create ALBs and NLBs, follow the [Setup Guide][2]. [2]: https://github.com/zalando-incubator/kube-ingress-aws-controller/tree/HEAD/deploy ### Optional RouteGroup [RouteGroup][3] is a CRD, that enables you to do complex routing with [Skipper][4]. First, you have to apply the RouteGroup CRD to your cluster: ```sh kubectl apply -f https://github.com/zalando/skipper/blob/HEAD/dataclients/kubernetes/deploy/apply/routegroups_crd.yaml ``` You have to grant all controllers: [Skipper][4], [kube-ingress-aws-controller][1] and external-dns to read the routegroup resource and kube-ingress-aws-controller to update the status field of a routegroup. This depends on your RBAC policies, in case you use RBAC, you can use this for all 3 controllers: ```yaml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: kube-ingress-aws-controller rules: - apiGroups: - extensions - networking.k8s.io resources: - ingresses verbs: - get - list - watch - apiGroups: - extensions - networking.k8s.io resources: - ingresses/status verbs: - patch - update - apiGroups: - zalando.org resources: - routegroups verbs: - get - list - watch - apiGroups: - zalando.org resources: - routegroups/status verbs: - patch - update ``` See also current RBAC yaml files: - [kube-ingress-aws-controller](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/ingress-controller/01-rbac.yaml) - [skipper](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/skipper/rbac.yaml) - [external-dns](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/external-dns/01-rbac.yaml) [3]: https://opensource.zalando.com/skipper/kubernetes/routegroups/#routegroups [4]: https://opensource.zalando.com/skipper ## Deploy an example application Create the following sample "echoserver" application to demonstrate how ExternalDNS works with ingress objects, that were created by [kube-ingress-aws-controller][1]. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: echoserver spec: replicas: 1 selector: matchLabels: app: echoserver template: metadata: labels: app: echoserver spec: containers: - image: gcr.io/google_containers/echoserver:1.4 imagePullPolicy: Always name: echoserver ports: - containerPort: 8080 --- apiVersion: v1 kind: Service metadata: name: echoserver spec: ports: - port: 80 targetPort: 8080 protocol: TCP type: ClusterIP selector: app: echoserver ``` Note that the Service object is of type `ClusterIP`, because we will target [Skipper][4] and do the HTTP routing in Skipper. We don't need a Service of type `LoadBalancer` here, since we will be using a shared skipper-ingress for all Ingress. Skipper use `hostNetwork` to be able to get traffic from AWS LoadBalancers EC2 network. ALBs or NLBs, will be created based on need and will be shared across all ingress as default. ## Ingress examples Create the following Ingress to expose the echoserver application to the Internet. ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: echoserver spec: ingressClassName: skipper rules: - host: echoserver.mycluster.example.org http: &echoserver_root paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix - host: echoserver.example.org http: *echoserver_root ``` The above should result in the creation of an (ipv4) ALB in AWS which will forward traffic to skipper which will forward to the echoserver application. If the `--source=ingress` argument is specified, then ExternalDNS will create DNS records based on the hosts specified in ingress objects. The above example would result in two alias records (A and AAAA) being created for each of the domains: `echoserver.mycluster.example.org` and `echoserver.example.org`. All four records alias the ALB that is associated with the Ingress object. As the ALB is IPv4 only, the AAAA alias records have no effect. If you would like ExternalDNS to not create AAAA records at all, you can add the following command line parameter: `--exclude-record-types=AAAA`. Please be aware, this will disable AAAA record creation even for dualstack enabled load balancers. Note that the above example makes use of the YAML anchor feature to avoid having to repeat the http section for multiple hosts that use the exact same paths. If this Ingress object will only be fronting one backend Service, we might instead create the following: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: external-dns.alpha.kubernetes.io/hostname: echoserver.mycluster.example.org, echoserver.example.org name: echoserver spec: ingressClassName: skipper rules: - http: paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix ``` In the above example we create a default path that works for any hostname, and make use of the `external-dns.alpha.kubernetes.io/hostname` annotation to create multiple aliases for the resulting ALB. ## NLBs AWS has [NLBs](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html) and [kube-ingress-aws-controller][1] is able to create NLBs instead of ALBs. The Kubernetes Ingress AWS controller supports the `zalando.org/aws-load-balancer-type` annotation (which defaults to `alb`) to determine this. If this annotation is set to `nlb` then ExternalDNS will create an NLB instead of an ALB. Example: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: zalando.org/aws-load-balancer-type: nlb name: echoserver spec: ingressClassName: skipper rules: - host: echoserver.example.org http: paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix ``` The above Ingress object will result in the creation of an NLB. A successful create, you can observe in the ingress `status` field, that is written by [kube-ingress-aws-controller][1]: ```yaml status: loadBalancer: ingress: - hostname: kube-ing-lb-atedkrlml7iu-1681027139.$region.elb.amazonaws.com ``` ExternalDNS will create A and AAAA alias records for: `echoserver.example.org`. ExternalDNS will use these alias records to automatically maintain IP addresses of the NLB. ## Dualstack Load Balancers AWS [supports both IPv4 and "dualstack" (both IPv4 and IPv6) interfaces for ALBs][5] and [NLBs][6]. The Kubernetes Ingress AWS controller supports the `alb.ingress.kubernetes.io/ip-address-type` annotation (which defaults to `ipv4`) to determine this. ExternalDNS creates both A and AAAA alias DNS records by default, regardless of this annotation. It's possible to create only A records with the following command line parameter: `--exclude-record-types=AAAA` [5]: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#ip-address-type [6]: https://docs.aws.amazon.com/elasticloadbalancing/latest/network/network-load-balancers.html#ip-address-type Example: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: annotations: alb.ingress.kubernetes.io/ip-address-type: dualstack name: echoserver spec: ingressClassName: skipper rules: - host: echoserver.example.org http: paths: - path: / backend: service: name: echoserver port: number: 80 pathType: Prefix ``` The above Ingress object will result in the creation of an ALB with a dualstack interface. ## RouteGroup (optional) [Kube-ingress-aws-controller][1], [Skipper][4] and external-dns support [RouteGroups][3]. External-dns needs to be started with `--source=skipper-routegroup` parameter in order to work on RouteGroup objects. Here we can not show [all RouteGroup capabilities](https://opensource.zalando.com/skipper/kubernetes/routegroups/), but we show one simple example with an application and a custom https redirect. ```yaml apiVersion: zalando.org/v1 kind: RouteGroup metadata: name: my-route-group spec: backends: - name: my-backend type: service serviceName: my-service servicePort: 80 - name: redirectShunt type: shunt defaultBackends: - backendName: my-service routes: - pathSubtree: / - pathSubtree: / predicates: - Header("X-Forwarded-Proto", "http") filters: - redirectTo(302, "https:") backends: - redirectShunt ``` ================================================ FILE: docs/tutorials/linode.md ================================================ # Linode This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Linode DNS Manager. Make sure to use **>=0.5.5** version of ExternalDNS for this tutorial. ## Managing DNS with Linode If you want to learn about how to use Linode DNS Manager read the following tutorials: [An Introduction to Managing DNS](https://www.linode.com/docs/platform/manager/dns-manager/), and [general documentation](https://www.linode.com/docs/networking/dns/) ## Creating Linode Credentials Generate a new oauth token by following the instructions at [Access-and-Authentication](https://developers.linode.com/api/v4#section/Access-and-Authentication) The environment variable `LINODE_TOKEN` will be needed to run ExternalDNS with Linode. ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=linode env: - name: LINODE_TOKEN value: "YOUR_LINODE_API_KEY" ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=linode env: - name: LINODE_TOKEN value: "YOUR_LINODE_API_KEY" ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: my-app.example.com spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Note the annotation on the service; use the same hostname as the Linode DNS zone created above. ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. Create the deployment and service: ```console kubectl create -f nginx.yaml ``` Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Linode DNS records. ## Verifying Linode DNS records Check your [Linode UI](https://cloud.linode.com/domains) to view the records for your Linode DNS zone. Click on the zone for the one created above if a different domain was used. This should show the external IP address of the service as the A record for your domain. ## Cleanup Now that we have verified that ExternalDNS will automatically manage Linode DNS records, we can delete the tutorial's example: ```sh kubectl delete service -f nginx.yaml kubectl delete service -f externaldns.yaml ``` ================================================ FILE: docs/tutorials/myra.md ================================================ # Myra ExternalDNS Webhook This guide provides quick instructions for setting up and testing the [Myra ExternalDNS Webhook](https://github.com/Myra-Security-GmbH/external-dns-myrasec-webhook) in a Kubernetes environment. ## Prerequisites - Kubernetes cluster (v1.19+) - `kubectl` configured to access your cluster - Docker for building the container image - MyraSec API credentials (API key and secret) - Domain registered with MyraSec ## Quick Installation ### 1. Get the Docker Image #### Pull from container registry The image is published with each version to Github Container Registry under [external-dns-myrasec-webhook](https://github.com/Myra-Security-GmbH/external-dns-myrasec-webhook/pkgs/container/external-dns-myrasec-webhook). ```bash # Pull the image docker pull ghcr.io/myra-security-gmbh/external-dns-myrasec-webhook: # For the sake of this tutorial, tag the image with "myra-webhook:latest" docker image tag ghcr.io/myra-security-gmbh/external-dns-myrasec-webhook: myra-webhook:latest ``` #### Build and Push the Docker Image ```bash # From the project root docker build -t myra-webhook:latest . # Tag the image for your container registry docker tag myra-webhook:latest /myra-webhook:latest # Push to your container registry docker push /myra-webhook:latest ``` > **Important**: The image must be pushed to a container registry accessible by your Kubernetes cluster. Update the image reference in the deployment YAML file to match your registry path. ### 2. Configure API Credentials Create a secret with your MyraSec API credentials: ```bash kubectl create secret generic myra-webhook-secrets \ --from-literal=myrasec-api-key=YOUR_API_KEY \ --from-literal=myrasec-api-secret=YOUR_API_SECRET \ --from-literal=domain-filter=YOUR_DOMAIN.com ``` Alternatively, apply the provided secret template after editing: ```bash # Edit the secret file first vi deploy/myra-webhook-secrets.yaml # Then apply kubectl apply -f deploy/myra-webhook-secrets.yaml ``` ### 3. Deploy the Webhook and ExternalDNS ```bash # Apply the combined deployment kubectl apply -f deploy/combined-deployment.yaml ``` This deploys: - ConfigMap with webhook configuration - ServiceAccount, ClusterRole, and ClusterRoleBinding for RBAC - Deployment with two containers: - myra-webhook: The webhook provider implementation - external-dns: The ExternalDNS controller using the webhook provider ### 4. Verify Deployment ```bash # Check if pods are running kubectl get pods -l app=myra-externaldns # Check logs for the webhook container kubectl logs -l app=myra-externaldns -c myra-webhook # Check logs for the external-dns container kubectl logs -l app=myra-externaldns -c external-dns ``` ## Manual Testing with NGINX Demo ### 1. Deploy the NGINX Demo Application ```bash # Edit the domain in the nginx-demo.yaml file to match your domain vi deploy/nginx-demo.yaml # Most important part is to set the correct domain in the external-dns.alpha.kubernetes.io/hostname annotation # Example: # annotations: # external-dns.alpha.kubernetes.io/enabled: "true" # external-dns.alpha.kubernetes.io/hostname: "nginx-demo.dummydomainforkubes.de" # external-dns.alpha.kubernetes.io/target: "9.2.3.4" # Apply the demo resources kubectl apply -f deploy/nginx-demo.yaml ``` This creates: - NGINX Deployment - Service for the deployment - Ingress resource with ExternalDNS annotations ### 2. Verify DNS Record Creation After deploying the demo application, ExternalDNS should automatically create DNS records in MyraSec: ```bash # Check external-dns logs to see record creation kubectl logs -l app=myra-externaldns -c external-dns | grep "nginx-demo" # Verify the webhook logs kubectl logs -l app=myra-externaldns -c myra-webhook | grep "Created DNS record" ``` You can also verify through the MyraSec dashboard that the records were created. ### 3. Testing Record Deletion To test record deletion: ```bash # Delete the nginx-demo resources or remove annotation from ingress kubectl delete -f deploy/nginx-demo.yaml # Delete the ingress resource or remove annotation from ingress # If resource is still active, external dns might still see the record and manage it kubectl delete ingress nginx-demo -n default # Check external-dns logs to see record deletion kubectl logs -l app=myra-externaldns -c external-dns | grep "nginx-demo" | grep "delete" # Verify the webhook logs kubectl logs -l app=myra-externaldns -c myra-webhook | grep "Deleted DNS record" ``` ## Configuration Options The webhook can be configured through the ConfigMap: | Parameter | Description | Default | | ------------------------ | ------------------------------------------------- | --------- | | `disable-protection` | Disabled Myra protection for DNS records | `"false"` | | `dry-run` | Run in dry-run mode without making actual changes | `"false"` | | `environment` | Environment name (affects private IP handling) | `"prod"` | | `log-level` | Logging level (debug, info, warn, error) | `"debug"` | | `ttl` | Default TTL for DNS records | `"300"` | | `webhook-listen-address` | Address and port for the webhook server | `":8080"` | ## Troubleshooting ### Common Issues 1. **Webhook not receiving requests** - Ensure the `webhook-provider-url` in the external-dns args is correct - Check network connectivity between containers 2. **DNS records not being created** - Verify MyraSec API credentials are correct - Check if the domain filter is properly configured - Look for error messages in the webhook and external-dns logs 3. **Permissions issues** - Ensure the ServiceAccount has the correct RBAC permissions ### Getting Help For more detailed logs: ```bash # Set log level to debug in the ConfigMap kubectl edit configmap myra-externaldns-config # Change log-level to "debug" # Restart the pods kubectl rollout restart deployment myra-externaldns ``` ## Environment Configuration The webhook supports different environment configurations through the `environment` setting in the ConfigMap: ```yaml apiVersion: v1 kind: ConfigMap metadata: name: myra-externaldns-config data: environment: "prod" # Can be "prod", "staging", "dev", etc. ``` The environment setting affects how the webhook handles certain operations: | Environment | Behavior | | ---------------------------------- | ----------------------------------------------------------------------- | | `prod`, `production`, `staging` | Strict mode: Skips private IP records, enforces stricter validation | | `dev`, `development`, `test`, etc. | Development mode: Allows private IP records, more permissive validation | To modify the environment: ```bash # Edit the ConfigMap directly kubectl edit configmap myra-externaldns-config # Or apply an updated YAML file kubectl apply -f updated-config.yaml ``` ## Advanced Configuration For production deployments, consider: 1. Using a proper image registry instead of `latest` tag 2. Setting resource limits appropriate for your environment 3. Configuring horizontal pod autoscaling 4. Using Helm for deployment management ================================================ FILE: docs/tutorials/ns1.md ================================================ # NS1 This tutorial describes how to setup ExternalDNS for use within a Kubernetes cluster using NS1 DNS. Make sure to use **>=0.5** version of ExternalDNS for this tutorial. ## Creating a zone with NS1 DNS If you are new to NS1, we recommend you first read the following instructions for creating a zone. [Creating a zone using the NS1 portal](https://ns1.com/knowledgebase/creating-a-zone) [Creating a zone using the NS1 API](https://ns1.com/api#put-create-a-new-dns-zone) ## Creating NS1 Credentials All NS1 products are API-first, meaning everything that can be done on the portal---including managing zones and records, data sources and feeds, and account settings and users---can be done via API. The NS1 API is a standard REST API with JSON responses. The environment var `NS1_APIKEY` will be needed to run ExternalDNS with NS1. ### To add or delete an API key 1. Log into the NS1 portal at [my.nsone.net](http://my.nsone.net). 2. Click your username in the upper-right corner, and navigate to **Account Settings** \> **Users & Teams**. 3. Navigate to the _API Keys_ tab, and click **Add Key**. 4. Enter the name of the application and modify permissions and settings as desired. Once complete, click **Create Key**. The new API key appears in the list. > [!NOTE] > Set the permissions for your API keys just as you would for a user or team associated with your organization's NS1 account. > For more information, refer to the article [Creating and Managing API Keys](https://help.ns1.com/hc/en-us/articles/360026140094-Creating-managing-users) in the NS1 Knowledge Base. ## Deploy ExternalDNS Connect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment: Begin by creating a Kubernetes secret to securely store your NS1 API key. This key will enable ExternalDNS to authenticate with NS1: ```shell kubectl create secret generic NS1_APIKEY --from-literal=NS1_API_KEY=YOUR_NS1_API_KEY ``` Ensure to replace YOUR_NS1_API_KEY with your actual NS1 API key. Then apply one of the following manifests file to deploy ExternalDNS. ## Using Helm Create a values.yaml file to configure ExternalDNS to use NS1 as the DNS provider. This file should include the necessary environment variables: ```shell provider: name: ns1 env: - name: NS1_APIKEY valueFrom: secretKeyRef: name: NS1_APIKEY key: NS1_API_KEY ``` Finally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file: ```shell helm upgrade --install external-dns external-dns/external-dns --values values.yaml ``` ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=ns1 env: - name: NS1_APIKEY valueFrom: secretKeyRef: name: NS1_APIKEY key: NS1_API_KEY ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=ns1 env: - name: NS1_APIKEY valueFrom: secretKeyRef: name: NS1_APIKEY key: NS1_API_KEY ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: example.com external-dns.alpha.kubernetes.io/ttl: "120" #optional spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` **A note about annotations** Verify that the annotation on the service uses the same hostname as the NS1 DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). The TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10. ExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records. ### Create the deployment and service ```sh kubectl create -f nginx.yaml ``` Depending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the NS1 DNS records. ## Verifying NS1 DNS records Use the NS1 portal or API to verify that the A record for your domain shows the external IP address of the services. ## Cleanup Once you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example: ```sh kubectl delete -f nginx.yaml kubectl delete -f externaldns.yaml ``` ================================================ FILE: docs/tutorials/oracle.md ================================================ # Oracle Cloud Infrastructure This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using OCI DNS. Make sure to use the latest version of ExternalDNS for this tutorial. ## Creating an OCI DNS Zone Create a DNS zone which will contain the managed DNS records. Let's use `example.com` as a reference here. Make note of the OCID of the compartment in which you created the zone; you'll need to provide that later. For more information about [OCI DNS see the documentation here][1]. ## Using Private OCI DNS Zones By default, the ExternalDNS OCI provider is configured to use Global OCI DNS Zones. If you want to use Private OCI DNS Zones, add the following argument to the ExternalDNS controller: ```sh --oci-zone-scope=PRIVATE ``` To use both Global and Private OCI DNS Zones, set the OCI Zone Scope to be empty: ```sh --oci-zone-scope= ``` ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. The OCI provider supports two authentication options: key-based and instance principals. ### Key-based We first need to create a config file containing the information needed to connect with the OCI API. Create a new file (oci.yaml) and modify the contents to match the example below. Be sure to adjust the values to match your own credentials, and the OCID of the compartment containing the zone: ```yaml auth: region: us-phoenix-1 tenancy: ocid1.tenancy.oc1... user: ocid1.user.oc1... key: | -----BEGIN RSA PRIVATE KEY----- -----END RSA PRIVATE KEY----- fingerprint: af:81:71:8e... # Omit if there is not a password for the key passphrase: Tx1jRk... compartment: ocid1.compartment.oc1... ``` Create a secret using the config file above: ```shell kubectl create secret generic external-dns-config --from-file=oci.yaml ``` ### OCI IAM Instance Principal If you're running ExternalDNS within OCI, you can use OCI IAM instance principals to authenticate with OCI. This obviates the need to create the secret with your credentials. You'll need to ensure an OCI IAM policy exists with a statement granting the `manage dns` permission on zones and records in the target compartment to the dynamic group covering your instance running ExternalDNS. E.g.: ```sql Allow dynamic-group to manage dns in compartment id ``` You'll also need to add the `--oci-auth-instance-principal` flag to enable this type of authentication. Finally, you'll need to add the `--oci-compartment-ocid=ocid1.compartment.oc1...` flag to provide the OCID of the compartment containing the zone to be managed. For more information about OCI IAM instance principals, see [the documentation here][2]. For more information about OCI IAM policy details for the DNS service, see [the documentation here][3]. ### OCI IAM Workload Identity If you're running ExternalDNS within an OCI Container Engine for Kubernetes (OKE) cluster, you can use OCI IAM Workload Identity to authenticate with OCI. You'll need to ensure an OCI IAM policy exists with a statement granting the `manage dns` permission on zones and records in the target compartment covering your OKE cluster running ExternalDNS. E.g.: ```sql Allow any-user to manage dns in compartment where all {request.principal.type='workload',request.principal.cluster_id='',request.principal.service_account='external-dns'} ``` You'll also need to create a new file (oci.yaml) and modify the contents to match the example below. Be sure to adjust the values to match your region and the OCID of the compartment containing the zone: ```yaml auth: region: us-phoenix-1 useWorkloadIdentity: true compartment: ocid1.compartment.oc1... ``` Create a secret using the config file above: ```shell kubectl create secret generic external-dns-config --from-file=oci.yaml ``` ## Manifest (for clusters with RBAC enabled) Apply the following manifest to deploy ExternalDNS. ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service - --source=ingress - --provider=oci - --policy=upsert-only # prevent ExternalDNS from deleting any records, omit to enable full synchronization - --txt-owner-id=my-identifier # Specifies the OCI DNS Zone scope, defaults to GLOBAL. # May be GLOBAL, PRIVATE, or an empty value to specify both GLOBAL and PRIVATE OCI DNS Zones # - --oci-zone-scope=GLOBAL # Specifies the zone cache duration, defaults to 0s. If set to 0s, the zone cache is disabled. # Use of zone caching is recommended to reduce the amount of requests sent to OCI DNS. # - --oci-zones-cache-duration=0s volumeMounts: - name: config mountPath: /etc/kubernetes/ volumes: - name: config secret: secretName: external-dns-config ``` ## Verify ExternalDNS works (Service example) Create the following sample application to test that ExternalDNS works. > For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value. ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: example.com spec: type: LoadBalancer ports: - port: 80 name: http targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 name: http ``` Apply the manifest above and wait roughly two minutes and check that a corresponding DNS record for your service was created. ```sh kubectl apply -f nginx.yaml ``` [1]: https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm [2]: https://docs.cloud.oracle.com/iaas/Content/Identity/Reference/dnspolicyreference.htm [3]: https://docs.cloud.oracle.com/iaas/Content/Identity/Tasks/callingservicesfrominstances.htm ================================================ FILE: docs/tutorials/ovh.md ================================================ # OVHcloud This tutorial describes how to setup ExternalDNS for use within a Kubernetes cluster using OVHcloud DNS. Make sure to use **>=0.6** version of ExternalDNS for this tutorial. ## Creating a zone with OVHcloud DNS If you are new to OVHcloud, we recommend you first read the following instructions for creating a zone. [Creating a zone using the OVHcloud Manager](https://help.ovhcloud.com/csm/en-gb-dns-create-dns-zone?id=kb_article_view&sysparm_article=KB0051667/) [Creating a zone using the OVHcloud API](https://api.ovh.com/console/) ## Creating OVHcloud Credentials You first need to create an OVHcloud application: follow the [OVHcloud documentation](https://help.ovhcloud.com/csm/en-gb-api-getting-started-ovhcloud-api?id=kb_article_view&sysparm_article=KB0042784#advanced-usage-pair-ovhcloud-apis-with-an-application) you will have your `Application key` and `Application secret` And you will need to generate your consumer key, here the permissions needed : - GET on `/domain/zone` - GET on `/domain/zone/*/record` - GET on `/domain/zone/*/record/*` - PUT on `/domain/zone/*/record/*` - POST on `/domain/zone/*/record` - DELETE on `/domain/zone/*/record/*` - GET on `/domain/zone/*/soa` - POST on `/domain/zone/*/refresh` You can use the following `curl` request to generate & validated your `Consumer key` ```bash curl -XPOST -H "X-Ovh-Application: " -H "Content-type: application/json" https://eu.api.ovh.com/1.0/auth/credential -d '{ "accessRules": [ { "method": "GET", "path": "/domain/zone" }, { "method": "GET", "path": "/domain/zone/*/soa" }, { "method": "GET", "path": "/domain/zone/*/record" }, { "method": "GET", "path": "/domain/zone/*/record/*" }, { "method": "PUT", "path": "/domain/zone/*/record/*" }, { "method": "POST", "path": "/domain/zone/*/record" }, { "method": "DELETE", "path": "/domain/zone/*/record/*" }, { "method": "POST", "path": "/domain/zone/*/refresh" } ], "redirection":"https://github.com/kubernetes-sigs/external-dns/blob/HEAD/docs/tutorials/ovh.md#creating-ovh-credentials" }' ``` ## Deploy ExternalDNS Connect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment: ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=ovh env: - name: OVH_APPLICATION_KEY value: "YOUR_OVH_APPLICATION_KEY" - name: OVH_APPLICATION_SECRET value: "YOUR_OVH_APPLICATION_SECRET" - name: OVH_CONSUMER_KEY value: "YOUR_OVH_CONSUMER_KEY_AFTER_VALIDATED_LINK" ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=ovh env: - name: OVH_APPLICATION_KEY value: "YOUR_OVH_APPLICATION_KEY" - name: OVH_APPLICATION_SECRET value: "YOUR_OVH_APPLICATION_SECRET" - name: OVH_CONSUMER_KEY value: "YOUR_OVH_CONSUMER_KEY_AFTER_VALIDATED_LINK" ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: example.com external-dns.alpha.kubernetes.io/ttl: "120" #optional spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` **A note about annotations** Verify that the annotation on the service uses the same hostname as the OVHcloud DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). The TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10. ExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records. ### Create the deployment and service ```sh kubectl create -f nginx.yaml ``` Depending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the OVHcloud DNS records. ## Verifying OVHcloud DNS records Use the OVHcloud manager or API to verify that the A record for your domain shows the external IP address of the services. ## Cleanup Once you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example: ```sh kubectl delete -f nginx.yaml kubectl delete -f externaldns.yaml ``` ================================================ FILE: docs/tutorials/pdns.md ================================================ # PowerDNS ## Prerequisites The provider has been written for and tested against [PowerDNS](https://github.com/PowerDNS/pdns) v4.1.x and thus requires **PowerDNS Auth Server >= 4.1.x** PowerDNS provider support was added via [this PR](https://github.com/kubernetes-sigs/external-dns/pull/373), thus you need to use external-dns version >= v0.5 The PDNS provider expects that your PowerDNS instance is already setup and functional. It expects that zones, you wish to add records to, already exist and are configured correctly. It does not add, remove or configure new zones in anyway. ## Feature Support The PDNS provider currently does not support: * Dry running a configuration is not supported ## Deployment Deploying external DNS for PowerDNS is actually nearly identical to deploying it for other providers. This is what a sample `deployment.yaml` looks like: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: # Only use if you're also using RBAC # serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # or ingress or both - --provider=pdns - --pdns-server={{ pdns-api-url }} - --pdns-server-id={{ pdns-server-id }} - --pdns-api-key={{ pdns-http-api-key }} - --txt-owner-id={{ owner-id-for-this-external-dns }} - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the zones matching provided domain; omit to process all available zones in PowerDNS - --log-level=debug - --interval=30s ``` ### Domain Filter (`--domain-filter`) When the `--domain-filter` argument is specified, external-dns will only create DNS records for host names (specified in ingress objects and services with the external-dns annotation) related to zones that match the `--domain-filter` argument in the external-dns deployment manifest. eg. ```--domain-filter=example.org``` will allow for zone `example.org` and any zones in PowerDNS that ends in `.example.org`, including `an.example.org`, ie. the subdomains of example.org. eg. ```--domain-filter=.example.org``` will allow *only* zones that end in `.example.org`, ie. the subdomains of example.org but not the `example.org` zone itself. The filter can also match parent zones. For example `--domain-filter=a.example.com` will allow for zone `example.com`. If you want to match parent zones, you cannot pre-pend your filter with a ".", eg. `--domain-filter=.example.com` will not attempt to match parent zones. ### Regex Domain Filter (`--regex-domain-filter`) `--regex-domain-filter` limits possible domains and target zone with a regex. It overrides domain filters and can be specified only once. ## RBAC If your cluster is RBAC enabled, you also need to setup the following, before you can run external-dns: ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["pods"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default ``` ## Testing and Verification **Important!**: Remember to change `example.com` with your own domain throughout the following text. Spin up a simple "Hello World" HTTP server with the following spec (`kubectl apply -f`): ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: echo spec: selector: matchLabels: app: echo template: metadata: labels: app: echo spec: containers: - image: hashicorp/http-echo name: echo ports: - containerPort: 5678 args: - -text="Hello World" --- apiVersion: v1 kind: Service metadata: name: echo annotations: external-dns.alpha.kubernetes.io/hostname: echo.example.com spec: selector: app: echo type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 5678 ``` **Important!**: Don't run dig, nslookup or similar immediately (until you've confirmed the record exists). You'll get hit by [negative DNS caching](https://tools.ietf.org/html/rfc2308), which is hard to flush. Run the following to make sure everything is in order: ```bash kubectl get services echo kubectl get endpoints echo ``` Make sure everything looks correct, i.e the service is defined and receives a public IP, and that the endpoint also has a pod IP. Once that's done, wait about 30s-1m (interval for external-dns to kick in), then do: ```bash curl -H "X-API-Key: ${PDNS_API_KEY}" ${PDNS_API_URL}/api/v1/servers/localhost/zones/example.com. | jq '.rrsets[] | select(.name | contains("echo"))' ``` Once the API shows the record correctly, you can double check your record using: ```bash dig @${PDNS_FQDN} echo.example.com. ``` ## Using CRD source to manage DNS records in PowerDNS Please refer to the [CRD source documentation](../sources/crd.md#example) for more information. ================================================ FILE: docs/tutorials/pihole.md ================================================ # Pi-hole This tutorial describes how to setup ExternalDNS to sync records with Pi-hole's Custom DNS. Pi-hole has an internal list it checks last when resolving requests. This list can contain any number of arbitrary A, AAAA or CNAME records. There is a pseudo-API exposed that ExternalDNS is able to use to manage these records. __NOTE:__ Your Pi-hole must be running [version 5.9 or newer](https://pi-hole.net/blog/2022/02/12/pi-hole-ftl-v5-14-web-v5-11-and-core-v5-9-released). __NOTE:__ Provider for Pi-hole version prior to 6.0 is now deprecated and will be removed in future release. __NOTE:__ Since Pi-hole version 6, you should use the flag *--pihole-api-version=6* ## Deploy ExternalDNS You can skip to the [manifest](#externaldns-manifest) if authentication is disabled on your Pi-hole instance or you don't want to use secrets. If your Pi-hole server's admin dashboard is protected by a password, you'll likely want to create a secret first containing its value. This is optional since you *do* retain the option to pass it as a flag with `--pihole-password`. You can create the secret with: ```bash kubectl create secret generic pihole-password \ --from-literal EXTERNAL_DNS_PIHOLE_PASSWORD=supersecret ``` Replacing __"supersecret"__ with the actual password to your Pi-hole server. ### ExternalDNS Manifest Apply the following manifest to deploy ExternalDNS, editing values for your environment accordingly. Be sure to change the namespace in the `ClusterRoleBinding` if you are using a namespace other than __default__. ```yaml --- apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list","watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 # If authentication is disabled and/or you didn't create # a secret, you can remove this block. envFrom: - secretRef: # Change this if you gave the secret a different name name: pihole-password args: - --source=service - --source=ingress # Pihole only supports A/AAAA/CNAME records so there is no mechanism to track ownership. # You don't need to set this flag, but if you leave it unset, you will receive warning # logs when ExternalDNS attempts to create TXT records. - --registry=noop # IMPORTANT: If you have records that you manage manually in Pi-hole, set # the policy to upsert-only so they do not get deleted. - --policy=upsert-only - --provider=pihole # Switch to pihole V6 API - --pihole-api-version=6 # Change this to the actual address of your Pi-hole web server - --pihole-server=http://pihole-web.pihole.svc.cluster.local securityContext: fsGroup: 65534 # For ExternalDNS to be able to read Kubernetes token files ``` ### Arguments - `--pihole-server (env: EXTERNAL_DNS_PIHOLE_SERVER)` - The address of the Pi-hole web server - `--pihole-password (env: EXTERNAL_DNS_PIHOLE_PASSWORD)` - The password to the Pi-hole web server (if enabled) - `--pihole-tls-skip-verify (env: EXTERNAL_DNS_PIHOLE_TLS_SKIP_VERIFY)` - Skip verification of any TLS certificates served by the Pi-hole web server. - `--pihole-api-version (env: EXTERNAL_DNS_PIHOLE_API_VERSION)` - Specify the pihole API version (default is 5. Eligible values are 5 or 6). ## Verify ExternalDNS Works ### Ingress Example Create an Ingress resource. ExternalDNS will use the hostname specified in the Ingress object. ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: foo spec: ingressClassName: nginx rules: - host: foo.bar.com http: paths: - path: / pathType: Prefix backend: service: name: foo port: number: 80 ``` ### Service Example The below sample application can be used to verify Services work. For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value. ```yaml --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.homelab.com spec: type: LoadBalancer ports: - port: 80 name: http targetPort: 80 selector: app: nginx --- apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 name: http ``` You can then query your Pi-hole to see if the record was created. Change *@192.168.100.2* to the actual address of your DNS server ```bash $ dig +short @192.168.100.2 nginx.external-dns-test.homelab.com 192.168.100.129 ``` ================================================ FILE: docs/tutorials/plural.md ================================================ # Plural This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Plural DNS. Make sure to use **>=0.12.3** version of ExternalDNS for this tutorial. ## Creating Plural Credentials A secret containing the a Plural access token is needed for this provider. You can get a token for your [user here](https://app.plural.sh/profile/tokens). To create the secret you can run `kubectl create secret generic plural-env --from-literal=PLURAL_ACCESS_TOKEN=`. ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. ## Using Helm Create a values.yaml file to configure ExternalDNS to use plural DNS as the DNS provider. This file should include the necessary environment variables: ```shell provider: name: plural extraArgs: - --plural-cluster=example-plural-cluster - --plural-provider=aws # gcp, azure, equinix and kind are also possible env: - name: PLURAL_ACCESS_TOKEN valueFrom: secretKeyRef: name: PLURAL_ACCESS_TOKEN key: plural-env - name: PLURAL_ENDPOINT value: https://app.plural.sh ``` Finally, install the ExternalDNS chart with Helm using the configuration specified in your values.yaml file: ```shell helm upgrade --install external-dns external-dns/external-dns --values values.yaml ``` ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=plural - --plural-cluster=example-plural-cluster - --plural-provider=aws # gcp, azure, equinix and kind are also possible env: - name: PLURAL_ACCESS_TOKEN valueFrom: secretKeyRef: key: PLURAL_ACCESS_TOKEN name: plural-env - name: PLURAL_ENDPOINT # (optional) use an alternative endpoint for Plural; defaults to https://app.plural.sh value: https://app.plural.sh ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=plural - --plural-cluster=example-plural-cluster - --plural-provider=aws # gcp, azure, equinix and kind are also possible env: - name: PLURAL_ACCESS_TOKEN valueFrom: secretKeyRef: key: PLURAL_ACCESS_TOKEN name: plural-env - name: PLURAL_ENDPOINT # (optional) use an alternative endpoint for Plural; defaults to https://app.plural.sh value: https://app.plural.sh ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: example.com spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Note the annotation on the service; use the same hostname as the Plural DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). By setting the TTL annotation on the service, you have to pass a valid TTL, which must be 120 or above. This annotation is optional, if you won't set it, it will be 1 (automatic) which is 300. ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. Create the deployment and service: ```sh kubectl create -f nginx.yaml ``` Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Plural DNS records. ## Verifying Plural DNS records Check your [Plural domain overview](https://app.plural.sh/account/domains) to view the domains associated with your Plural account. There you can view the records for each domain. The records should show the external IP address of the service as the A record for your domain. ## Cleanup Now that we have verified that ExternalDNS will automatically manage Plural DNS records, we can delete the tutorial's example: ```sh kubectl delete -f nginx.yaml kubectl delete -f externaldns.yaml ================================================ FILE: docs/tutorials/rfc2136.md ================================================ # RFC2136 provider This tutorial describes how to use the RFC2136 with either BIND or Windows DNS. ## Using with BIND To use external-dns with BIND: generate/procure a key, configure DNS and add a deployment of external-dns. ### Server credentials - RFC2136 was developed for and tested with [BIND](https://www.isc.org/downloads/bind/) DNS server. This documentation assumes that you already have a configured and working server. If you don't, please check BIND documents or tutorials. - If your DNS is provided for you, ask for a TSIG key authorized to update and transfer the zone you wish to update. The key will look something like below. Skip the next steps wrt BIND setup. ```text key "externaldns-key" { algorithm hmac-sha256; secret "96Ah/a2g0/nLeFGK+d/0tzQcccf9hCEIy34PoXX2Qg8="; }; ``` - If you are your own DNS administrator create a TSIG key. Use `tsig-keygen -a hmac-sha256 externaldns` or on older distributions `dnssec-keygen -a HMAC-SHA256 -b 256 -n HOST externaldns`. You will end up with a key printed to standard out like above (or in the case of dnssec-keygen in a file called `Kexternaldns......key`). ### BIND Configuration If you do not administer your own DNS, skip to RFC provider configuration - Edit your named.conf file (or appropriate included file) and add/change the following. - Make sure You are listening on the right interfaces. At least whatever interface external-dns will be communicating over and the interface that faces the internet. - Add the key that you generated/was given to you above. Copy paste the four lines that you got (not the same as the example key) into your file. - Make sure zone transfer is enabled for the key, this enables listing all records - Create a zone for kubernetes. If you already have a zone, skip to the next step. (I put the zone in it's own subdirectory because named, which shouldn't be running as root, needs to create a journal file and the default zone directory isn't writeable by named). ```text zone "k8s.example.org" { type master; file "/etc/bind/pri/k8s/k8s.zone"; }; ``` - Add your key to both transfer and update. For instance with our previous zone. ```text zone "k8s.example.org" { type master; file "/etc/bind/pri/k8s/k8s.zone"; allow-transfer { key "externaldns-key"; }; update-policy { grant externaldns-key zonesub ANY; }; }; ``` - Create a zone file (k8s.zone): ```text $TTL 60 ; 1 minute k8s.example.org IN SOA k8s.example.org. root.k8s.example.org. ( 16 ; serial 60 ; refresh (1 minute) 60 ; retry (1 minute) 60 ; expire (1 minute) 60 ; minimum (1 minute) ) NS ns.k8s.example.org. ns A 123.456.789.012 ``` - Reload (or restart) named ### AXFR and the sync policy When using the `sync` policy, ExternalDNS requires AXFR (zone transfer) to be explicitly enabled via the `--rfc2136-tsig-axfr` flag. This is necessary for ExternalDNS to list all existing DNS records and determine which ones should be lifecycled. Without `--rfc2136-tsig-axfr`, ExternalDNS cannot list records and will act as if the policy was set to `upsert-only`. No warning will be provided. ### Using external-dns To use external-dns add an ingress or a LoadBalancer service with a host that is part of the domain-filter. For example both of the following would produce A records. ```text apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: svc.example.org spec: type: LoadBalancer ports: - port: 80 targetPort: 80 selector: app: nginx --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: my-ingress spec: rules: - host: ingress.example.org http: paths: - path: / backend: serviceName: my-service servicePort: 8000 ``` ### Custom TTL The default DNS record TTL (Time-To-Live) is 0 seconds. You can customize this value by setting the annotation `external-dns.alpha.kubernetes.io/ttl`. e.g., modify the service manifest YAML file above: ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com external-dns.alpha.kubernetes.io/ttl: 60 spec: ... ``` This will set the DNS record's TTL to 60 seconds. A default TTL for all records can be set using the the flag with a time in seconds, minutes or hours, such as `--rfc2136-min-ttl=60s` There are other annotation that can affect the generation of DNS records, but these are beyond the scope of this tutorial and are covered in the main documentation. ### Generate reverse DNS records If you want to generate reverse DNS records for your services, you have to enable the functionality using the `--rfc2136-create-ptr` flag. You have also to add the zone to the list of zones managed by ExternalDNS via the `--rfc2136-zone` and `--domain-filter` flags. An example of a valid configuration is the following: ```sh --domain-filter=157.168.192.in-addr.arpa --rfc2136-zone=157.168.192.in-addr.arpa ``` PTR record tracking is managed by the A/AAAA record so you can't create PTR records for already generated A/AAAA records. ### Test with external-dns installed on local machine (optional) You may install external-dns and test on a local machine by running: ```sh external-dns --txt-owner-id k8s --provider rfc2136 \ --rfc2136-host=192.168.0.1 --rfc2136-port=53 \ --rfc2136-zone=k8s.example.org \ --rfc2136-tsig-secret=96Ah/a2g0/nLeFGK+d/0tzQcccf9hCEIy34PoXX2Qg8= \ --rfc2136-tsig-secret-alg=hmac-sha256 \ --rfc2136-tsig-keyname=externaldns-key \ --rfc2136-tsig-axfr \ --source ingress --once \ --domain-filter=k8s.example.org --dry-run ``` - host should be the IP of your master DNS server. - tsig-secret should be changed to match your secret. - tsig-keyname needs to match the keyname you used (if you changed it). - domain-filter can be used as shown to filter the domains you wish to update. ### RFC2136 provider configuration In order to use external-dns with your cluster you need to add a deployment with access to your ingress and service resources. The following are two example manifests with and without RBAC respectively. - With RBAC: ```text apiVersion: v1 kind: Namespace metadata: name: external-dns labels: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns namespace: external-dns rules: - apiGroups: - "" resources: - services - pods - nodes verbs: - get - watch - list - apiGroups: - discovery.k8s.io resources: - endpointslices verbs: - get - watch - list - apiGroups: - extensions - networking.k8s.io resources: - ingresses verbs: - get - list - watch --- apiVersion: v1 kind: ServiceAccount metadata: name: external-dns namespace: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer namespace: external-dns roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: external-dns --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns namespace: external-dns spec: selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --registry=txt - --txt-prefix=external-dns- - --txt-owner-id=k8s - --provider=rfc2136 - --rfc2136-host=192.168.0.1 - --rfc2136-port=53 - --rfc2136-zone=k8s.example.org - --rfc2136-zone=k8s.your-zone.org - --rfc2136-tsig-secret=96Ah/a2g0/nLeFGK+d/0tzQcccf9hCEIy34PoXX2Qg8= - --rfc2136-tsig-secret-alg=hmac-sha256 - --rfc2136-tsig-keyname=externaldns-key - --rfc2136-tsig-axfr - --source=ingress - --domain-filter=k8s.example.org ``` - Without RBAC: ```text apiVersion: v1 kind: Namespace metadata: name: external-dns labels: name: external-dns --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns namespace: external-dns spec: selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --registry=txt - --txt-prefix=external-dns- - --txt-owner-id=k8s - --provider=rfc2136 - --rfc2136-host=192.168.0.1 - --rfc2136-port=53 - --rfc2136-zone=k8s.example.org - --rfc2136-zone=k8s.your-zone.org - --rfc2136-tsig-secret=96Ah/a2g0/nLeFGK+d/0tzQcccf9hCEIy34PoXX2Qg8= - --rfc2136-tsig-secret-alg=hmac-sha256 - --rfc2136-tsig-keyname=externaldns-key - --rfc2136-tsig-axfr - --source=ingress - --domain-filter=k8s.example.org ``` ## Microsoft DNS While `external-dns` was not developed or tested against Microsoft DNS, it can be configured to work against it. YMMV. ### Secure Updates Using RFC3645 (GSS-TSIG) #### DNS-side configuration 1. Create a DNS zone 2. Enable **secure** dynamic updates for the zone 3. Enable Zone Transfers to all servers and/or other domains 4. Create a user with permissions to create/update/delete records in that zone If you see any error messages which indicate that `external-dns` was somehow not able to fetch existing DNS records from your DNS server, this could mean that you forgot about step 3. ##### Kerberos Configuration DNS with secure updates relies upon a valid Kerberos configuration running within the `external-dns` container. At this time, you will need to create a ConfigMap for the `external-dns` container to use and mount it in your deployment. Below is an example of a working Kerberos configuration inside a ConfigMap definition. This may be different depending on many factors in your environment: ```yaml apiVersion: v1 kind: ConfigMap metadata: creationTimestamp: null name: krb5.conf data: krb5.conf: | [logging] default = FILE:/var/log/krb5libs.log kdc = FILE:/var/log/krb5kdc.log admin_server = FILE:/var/log/kadmind.log [libdefaults] dns_lookup_realm = false ticket_lifetime = 24h renew_lifetime = 7d forwardable = true rdns = false pkinit_anchors = /etc/pki/tls/certs/ca-bundle.crt default_ccache_name = KEYRING:persistent:%{uid} default_realm = YOUR-REALM.COM [realms] YOUR-REALM.COM = { kdc = dc1.yourdomain.com admin_server = dc1.yourdomain.com } [domain_realm] yourdomain.com = YOUR-REALM.COM .yourdomain.com = YOUR-REALM.COM ``` In most cases, the realm name will probably be the same as the domain name, so you can simply replace `YOUR-REALM.COM` with something like `YOURDOMAIN.COM`. Once the ConfigMap is created, the container `external-dns` container needs to be told to mount that ConfigMap as a volume at the default Kerberos configuration location. The pod spec should include a similar configuration to the following: ```yaml ... volumeMounts: - mountPath: /etc/krb5.conf name: kerberos-config-volume subPath: krb5.conf ... volumes: - configMap: defaultMode: 420 name: krb5.conf name: kerberos-config-volume ... ``` ##### `external-dns` configuration You'll want to configure `external-dns` similarly to the following: ```text ... - --provider=rfc2136 - --rfc2136-gss-tsig - --rfc2136-host=dns-host.yourdomain.com - --rfc2136-port=53 - --rfc2136-zone=your-zone.com - --rfc2136-zone=your-secondary-zone.com - --rfc2136-kerberos-username=your-domain-account - --rfc2136-kerberos-password=your-domain-password - --rfc2136-kerberos-realm=your-domain.com - --rfc2136-tsig-axfr # needed to enable zone transfers, which is required for deletion of records. ... ``` As noted above, the `--rfc2136-kerberos-realm` flag is completely optional and won't be necessary in many cases. Most likely, you will only need it if you see errors similar to this: `KRB Error: (68) KDC_ERR_WRONG_REALM Reserved for future use`. The flag `--rfc2136-host` can be set to the host's domain name or IP address. However, it also determines the name of the Kerberos principal which is used during authentication. This means that Active Directory might only work if this is set to a specific domain name, possibly leading to errors like this: `KDC_ERR_S_PRINCIPAL_UNKNOWN Server not found in Kerberos database`. To fix this, try setting `--rfc2136-host` to the "actual" hostname of your DNS server. ### Insecure Updates #### DNS-side configuration 1. Create a DNS zone 2. Enable insecure dynamic updates for the zone 3. Enable Zone Transfers to all servers and/or other domains #### `external-dns` configuration You'll want to configure `external-dns` similarly to the following: ```text ... - --provider=rfc2136 - --rfc2136-host=192.168.0.1 - --rfc2136-port=53 - --rfc2136-zone=k8s.example.org - --rfc2136-zone=k8s.your-zone.org - --rfc2136-insecure - --rfc2136-tsig-axfr # needed to enable zone transfers, which is required for deletion of records. ... ``` ## DNS Over TLS (RFCs 7858 and 9103) If your DNS server does zone transfers over TLS, you can instruct `external-dns` to connect over TLS with the following flags: - `--rfc2136-use-tls` Will enable TLS for both zone transfers and for updates. - `--tls-ca=` Is the path to a file containing certificate(s) that can be used to verify the DNS server - `--tls-client-cert=` and - `--tls-client-cert-key=` Set the client certificate and key for mutual verification - `--rfc2136-skip-tls-verify` Disables verification of the certificate supplied by the DNS server. It is currently not supported to do only zone transfers over TLS, but not the updates. They are enabled and disabled together. ## Configuring RFC2136 Provider with Multiple Hosts and Load Balancing This section describes how to configure the RFC2136 provider in ExternalDNS to support multiple DNS servers and load balancing options. ### Enhancements Overview The RFC2136 provider now supports multiple DNS hosts and introduces load balancing options to distribute DNS update requests evenly across available DNS servers. This helps prevent a single server from becoming a bottleneck in environments with multiple DNS servers. ### Configuration Steps 1. **Allow Multiple Hosts for `--rfc2136-host`** - Modify the `--rfc2136-host` command-line option to accept multiple hosts. - Example: `--rfc2136-host="dns-host-1.yourdomain.com" --rfc2136-host="dns-host-2.yourdomain.com"` 2. **Introduce Load Balancing Options** - Add a new command-line option `--rfc2136-load-balancing-strategy` to specify the load balancing strategy. - Supported options: - `round-robin`: Distributes DNS updates evenly across all specified hosts in a round-robin manner. - `random`: Randomly selects a host for each DNS update. - `disabled` (default): Uses the first host in the list as the primary, only moving to the next host if a failure occurs. ### Example Configuration ```shell external-dns \ --provider=rfc2136 \ --rfc2136-host="dns-host-1.yourdomain.com" \ --rfc2136-host="dns-host-2.yourdomain.com" \ --rfc2136-host="dns-host-3.yourdomain.com" \ --rfc2136-load-balancing-strategy="round-robin" \ --rfc2136-port=53 \ --rfc2136-zone=example.com \ --rfc2136-tsig-secret-alg=hmac-sha256 \ --rfc2136-tsig-keyname=example-key \ --rfc2136-tsig-secret=example-secret \ --rfc2136-insecure ``` ### Helm ```yaml extraArgs: - --rfc2136-host="dns-host-1.yourdomain.com" - --rfc2136-port=53 - --rfc2136-zone=example.com - --rfc2136-tsig-secret-alg=hmac-sha256 - --rfc2136-tsig-axfr env: - name: "EXTERNAL_DNS_RFC2136_TSIG_SECRET" valueFrom: secretKeyRef: name: rfc2136-keys key: rfc2136-tsig-secret - name: "EXTERNAL_DNS_RFC2136_TSIG_KEYNAME" valueFrom: secretKeyRef: name: rfc2136-keys key: rfc2136-tsig-keyname ``` #### Secret creation ```shell kubectl create secret generic rfc2136-keys --from-literal=rfc2136-tsig-secret='xxx' --from-literal=rfc2136-tsig-keyname='k8s-external-dns-key' -n external-dns ``` ### Benefits - Distributes the load of DNS updates across multiple data centers, preventing any single DC from becoming a bottleneck. - Provides flexibility to choose different load balancing strategies based on the environment and requirements. - Improves the resilience and reliability of DNS updates by introducing a retry mechanism with a list of hosts. ================================================ FILE: docs/tutorials/scaleway.md ================================================ # Scaleway This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using Scaleway DNS. Make sure to use **>=0.7.4** version of ExternalDNS for this tutorial. **Warning**: Scaleway DNS is currently in Public Beta and may not be suited for production usage. ## Importing a Domain into Scaleway DNS In order to use your domain, you need to import it into Scaleway DNS. If it's not already done, you can follow [this documentation](https://www.scaleway.com/en/docs/scaleway-dns/) Once the domain is imported you can either use the root zone, or create a subzone to use. In this example we will use `example.com` as an example. ## Creating Scaleway Credentials To use ExternalDNS with Scaleway DNS, you need to create an API token (composed of the Access Key and the Secret Key). You can either use existing ones or you can create a new token, as explained in [How to generate an API token](https://www.scaleway.com/en/docs/generate-an-api-token/) or directly by going to the [credentials page](https://console.scaleway.com/account/organization/credentials). Scaleway provider supports configuring credentials using profiles or supplying it directly with environment variables. ### Configuration using a config file You can supply the credentials through a config file: 1. Create the config file. Check out [Scaleway docs](https://github.com/scaleway/scaleway-sdk-go/blob/master/scw/README.md#scaleway-config) for instructions 2. Mount it as a Secret into the Pod 3. Configure environment variable `SCW_PROFILE` to match the profile name in the config file 4. Configure environment variable `SCW_CONFIG_PATH` to match the location of the mounted config file ### Configuration using environment variables Two environment variables are needed to run ExternalDNS with Scaleway DNS: - `SCW_ACCESS_KEY` which is the Access Key. - `SCW_SECRET_KEY` which is the Secret Key. ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. The following example are suited for development. For a production usage, prefer secrets over environment, and use a [tagged release](https://github.com/kubernetes-sigs/external-dns/releases). ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: replicas: 1 selector: matchLabels: app: external-dns strategy: type: Recreate template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=scaleway env: - name: SCW_ACCESS_KEY value: "" - name: SCW_SECRET_KEY value: "" ### Set if configuring using a config file. Make sure to create the Secret first. # - name: SCW_PROFILE # value: "" # - name: SCW_CONFIG_PATH # value: /etc/scw/config.yaml # volumeMounts: # - name: scw-config # mountPath: /etc/scw/config.yaml # readOnly: true # volumes: # - name: scw-config # secret: # secretName: scw-config ### ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["list","watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: replicas: 1 selector: matchLabels: app: external-dns strategy: type: Recreate template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. - --provider=scaleway env: - name: SCW_ACCESS_KEY value: "" - name: SCW_SECRET_KEY value: "" ### Set if configuring using a config file. Make sure to create the Secret first. # - name: SCW_PROFILE # value: "" # - name: SCW_CONFIG_PATH # value: /etc/scw/config.yaml # volumeMounts: # - name: scw-config # mountPath: /etc/scw/config.yaml # readOnly: true # volumes: # - name: scw-config # secret: # secretName: scw-config ### ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: replicas: 1 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: my-app.example.com spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Note the annotation on the service; use the same hostname as the Scaleway DNS zone created above. ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. Create the deployment and service: ```console kubectl create -f nginx.yaml ``` Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the Scaleway DNS records. ## Verifying Scaleway DNS records Check your [Scaleway DNS UI](https://console.scaleway.com/domains/external) to view the records for your Scaleway DNS zone. Click on the zone for the one created above if a different domain was used. This should show the external IP address of the service as the A record for your domain. ## Cleanup Now that we have verified that ExternalDNS will automatically manage Scaleway DNS records, we can delete the tutorial's example: ```sh kubectl delete service -f nginx.yaml kubectl delete service -f externaldns.yaml ``` ================================================ FILE: docs/tutorials/security-context.md ================================================ # Running ExternalDNS with limited privileges You can run ExternalDNS with reduced privileges since `v0.5.6` using the following `SecurityContext`. ```yaml [[% include 'security-context/extdns-limited-privilege.yaml' %]] ``` ================================================ FILE: docs/tutorials/transip.md ================================================ # TransIP This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using TransIP. Make sure to use **>=0.5.14** version of ExternalDNS for this tutorial, have at least 1 domain registered at TransIP and enabled the API. ## Enable TransIP API and prepare your API key To use the TransIP API you need an account at TransIP and enable API usage as described in the [knowledge base](https://www.transip.eu/knowledgebase/entry/77-want-use-the-transip-api/). With the private key generated by the API, we create a kubernetes secret: ```console kubectl create secret generic transip-api-key --from-file=transip-api-key=/path/to/private.key ``` ## Deploy ExternalDNS Below are example manifests, for both cluster without or with RBAC enabled. Don't forget to replace `YOUR_TRANSIP_ACCOUNT_NAME` with your TransIP account name. In these examples, an example domain-filter is defined. Such a filter can be used to prevent ExternalDNS from touching any domain not listed in the filter. Refer to the docs for any other command-line parameters you might want to use. ### Manifest (for clusters without RBAC enabled) ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains - --provider=transip - --transip-account=YOUR_TRANSIP_ACCOUNT_NAME - --transip-keyfile=/transip/transip-api-key volumeMounts: - mountPath: /transip name: transip-api-key readOnly: true volumes: - name: transip-api-key secret: secretName: transip-api-key ``` ### Manifest (for clusters with RBAC enabled) ```yaml apiVersion: v1 kind: ServiceAccount metadata: name: external-dns --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["services","pods"] verbs: ["get","watch","list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get","watch","list"] - apiGroups: ["extensions","networking.k8s.io"] resources: ["ingresses"] verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] verbs: ["watch", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default --- apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns:v0.20.0 args: - --source=service # ingress is also possible - --domain-filter=example.com # (optional) limit to only example.com domains - --provider=transip - --transip-account=YOUR_TRANSIP_ACCOUNT_NAME - --transip-keyfile=/transip/transip-api-key volumeMounts: - mountPath: /transip name: transip-api-key readOnly: true volumes: - name: transip-api-key secret: secretName: transip-api-key ``` ## Deploying an Nginx Service Create a service file called 'nginx.yaml' with the following contents: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - image: nginx name: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx annotations: external-dns.alpha.kubernetes.io/hostname: my-app.example.com spec: selector: app: nginx type: LoadBalancer ports: - protocol: TCP port: 80 targetPort: 80 ``` Note the annotation on the service; this is the name ExternalDNS will create and manage DNS records for. ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. Create the deployment and service: ```console kubectl create -f nginx.yaml ``` Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the TransIP DNS records. ## Verifying TransIP DNS records Check your [TransIP Control Panel](https://transip.eu/cp) to view the records for your TransIP DNS zone. Click on the zone for the one created above if a different domain was used. This should show the external IP address of the service as the A record for your domain. ================================================ FILE: docs/tutorials/webhook-provider.md ================================================ # Webhook provider The "Webhook" provider allows integrating ExternalDNS with DNS providers through an HTTP interface. The Webhook provider implements the `Provider` interface. Instead of implementing code specific to a provider, it implements an HTTP client that sends requests to an HTTP API. The idea behind it is that providers can be implemented in separate programs: these programs expose an HTTP API that the Webhook provider interacts with. The ideal setup for providers is to run as a sidecar in the same pod of the ExternalDNS container, listening only on localhost. This is not strictly a requirement, but we do not recommend other setups. ## Architectural diagram ![Webhook provider](../img/webhook-provider.png) ## API guarantees Providers implementing the HTTP API have to keep in sync with changes to the JSON serialization of Go types `plan.Changes`, `endpoint.Endpoint`, and `endpoint.DomainFilter`. Given the maturity of the project, we do not expect to make significant changes to those types, but can't exclude the possibility that changes will need to happen. We commit to publishing changes to those in the release notes, to ensure that providers implementing the API can keep providers up to date quickly. ## Implementation requirements The following table represents the methods to implement mapped to their HTTP method and route. ### Provider endpoints | Provider method | HTTP Method | Route | Description | |-----------------|-------------|------------------|------------------------------------------| | Negotiate | GET | / | Negotiate `DomainFilter` | | Records | GET | /records | Get records | | AdjustEndpoints | POST | /adjustendpoints | Provider specific adjustments of records | | ApplyChanges | POST | /records | Apply record | OpenAPI [spec is here](../../api/webhook.yaml). ExternalDNS will also make requests to the `/` endpoint for negotiation and for deserialization of the `DomainFilter`. The server needs to respond to those requests by reading the `Accept` header and responding with a corresponding `Content-Type` header specifying the supported media type format and version. The default recommended port for the provider endpoints is `8888`, and should listen only on `localhost` (ie: only accessible for external-dns). **NOTE**: only `5xx` responses will be retried and only `20x` will be considered as successful. All status codes different from those will be considered a failure on ExternalDNS's side. **NOTE**: the `--webhook-provider-read-timeout` and `--webhook-provider-write-timeout` flags control the outbound HTTP client timeout. The total client timeout is the sum of both values and covers the full round-trip: writing the request body, waiting for the response, and reading the response body. Requests that exceed this deadline are cancelled and treated as a failure. ### Exposed endpoints | Provider method | HTTP Method | Route | Description | | --------------- | ----------- | -------- | -------------------------------------------------------------------------------------------- | | K8s probe | GET | /healthz | Used by `livenessProbe` and `readinessProbe` | | Open Metrics | GET | /metrics | Optional endpoint to expose [Open Metrics](https://github.com/OpenObservability/OpenMetrics) | The default recommended port for the exposed endpoints is `8080`, and it should be bound to all interfaces (`0.0.0.0`) ## Custom Annotations The Webhook provider supports custom annotations for DNS records. This feature allows users to define additional configuration options for DNS records managed by the Webhook provider. Custom annotations are defined using the annotation format `external-dns.alpha.kubernetes.io/webhook-`. Custom annotations can be used to influence DNS record creation and updates. Providers implementing the Webhook API should document the custom annotations they support and how they affect DNS record management. ## Best practices for webhook provider authors ### Status codes Use the correct HTTP status codes — they directly control ExternalDNS retry behaviour: | Situation | Status code | Effect | | --------- | ----------- | ------ | | Success (`Records`, `AdjustEndpoints`) | `200 OK` | Accepted | | Success (`ApplyChanges`) | `204 No Content` | Accepted | | Transient error (rate limit, upstream timeout, etc.) | `5xx` | Retried by ExternalDNS | | Permanent error (bad request, auth failure, etc.) | `4xx` | Not retried; logged as failure | | Redirects | `3xx` | Treated as a permanent failure; do not use | ### Response bodies and connection reuse ExternalDNS drains response bodies before closing them so that TCP connections can be returned to the pool and reused. To keep this effective: - **Always write a complete response body**, even for errors. An empty JSON object `{}` or a plain-text message is fine. - **Keep error response bodies small** (well under 1 MiB). ExternalDNS caps the drain at 1 MiB; bodies larger than that cause the connection to be discarded rather than pooled, increasing latency and resource usage on both sides. - **Do not stream indefinitely.** Finish writing the response and close it promptly. ### Timeouts and cancellation ExternalDNS propagates request context to all outbound calls. When the controller shuts down or a request times out, the in-flight HTTP connection is cancelled. Providers should: - Handle abrupt connection drops gracefully — do not treat a cancelled request as a reason to roll back partially applied changes without verifying state first. - Respond within the deadline configured by `--webhook-provider-read-timeout` and `--webhook-provider-write-timeout` (default values apply when unset). Long-running DNS API calls should have their own internal timeout shorter than the ExternalDNS deadline. ### Memory and goroutine hygiene - Close request bodies after reading them to avoid goroutine leaks on the webhook provider side. - Avoid holding references to decoded request payloads longer than needed; `plan.Changes` and endpoint slices can be large for zones with many records. ## Provider registry To simplify the discovery of providers, we will accept pull requests that will add links to providers in this documentation. This list will only serve the purpose of simplifying finding providers and will not constitute an official endorsement of any of the externally implemented providers unless otherwise stated. ## Run an ExternalDNS in-tree provider as a webhook To test the Webhook provider and provide a reference implementation, we added the functionality to run ExternalDNS as a webhook. To run the AWS provider as a webhook, you need the following flags: ```yaml - --webhook-server - --provider=aws - --source=ingress ``` The value of the `--source` flag is ignored in this mode. This will start the AWS provider as an HTTP server exposed only on localhost. In a separate process/container, run ExternalDNS with `--provider=webhook`. This is the same setup that we recommend for other providers and a good way to test the Webhook provider. ================================================ FILE: docs/version-update-playbook.md ================================================ # 🧭 External-DNS Version Upgrade Playbook ## Overview This playbook describes the best practices and steps to safely upgrade **External-DNS** in Kubernetes clusters. Upgrading External-DNS involves validating configuration compatibility, testing changes, and ensuring no unintended DNS record modifications occur. > Note; We strongly encourage the community to help the maintainers validate changes before they are merged or released. > Early validation and feedback are key to ensuring stable upgrades for everyone. --- ## 1. Review Release Notes - Visit the official [External-DNS Releases](https://github.com/kubernetes-sigs/external-dns/releases). - Review all versions between your current and target release. - Pay attention to: - **Breaking changes** (flags, CRD fields, provider behaviors). Not all changes could be captured as breaking changes. - **Deprecations** - **Provider-specific updates** - **Bug fixes** > ⚠️ Breaking CLI flag or annotation changes are common in `0.x` releases. --- ## 2. Review Helm Chart and Configuration If using Helm: - Compare your Helm chart version to the version supporting the new app release. - Check for: - `values.yaml` structural changes - Default arguments under `extraArgs` - Updates to RBAC, ServiceAccounts, or Deployment templates --- ## 3. Check Compatibility Before upgrading, confirm: - The new version supports your **Kubernetes version** (e.g., 1.25+). - The **DNS provider** integration you use is still supported. > 💡 Watch out for deprecated Kubernetes API versions (e.g., `v1/endpoints` → `discovery.k8s.io/v1/endpointslices`). --- ## 4. Test in Non-Production or with Dry Run flag Run the new External-DNS version in a **staging cluster**. - Use `--dry-run` mode to preview intended changes: - Validate logs for any unexpected record changes. - Ensure `external-dns` correctly identifies and plans updates without actually applying them. - **submit a feature request** if `dry-run` is not supported for a specific case ```yaml args: - --dry-run ``` --- 5. Backup DNS State Before applying the upgrade, take a snapshot of your DNS zone(s). **Example (AWS Route53):** ```sh aws route53 list-resource-record-sets --hosted-zone-id ZONE_ID > backup.json ``` Use equivalent tooling for your DNS provider (Cloudflare, Google Cloud DNS, etc.). > Having a backup ensures you can restore records if External-DNS misconfigures entries and you have a solid DR solution. 6. Perform a Controlled Rollout Instead of upgrading in-place, use a phased rollout across multiple environments or clusters. Recommended Approaches a. Multi-Cluster Rollout and Progression 1. Deploy the new `external-dns` version first in sandbox, then staging, and finally production. 2. Monitor each environment for correct record syncing and absence of unexpected deletions. 3. Promote the configuration only after validation in the lower environment. b. Read-Only Parallel Deployment 1. Run a second External-DNS instance (e.g., external-dns-readonly) with: ```yaml args: - --dry-run - ...other flags ``` 1. Observe logs and planned record updates to confirm behavior. 2. Observe logs and planned record updates to confirm behavior. 7. Monitor and Validate After deploying the new version, continuously observe both application logs and DNS synchronization metrics to ensure External-DNS behaves as expected. **Logging** Check logs for anomalies or unexpected record changes: ```yaml kubectl logs -n external-dns deploy/external-dns --tail=100 -f ``` Look for: - Creating record or Deleting record entries — validate these match expected changes. - `WARN` or `ERROR` messages, particularly related to provider authentication or permissions. - `TXT` registry conflicts (ownership issues between multiple instances). If using a centralized logging stack (e.g., Loki, Elasticsearch, or CloudWatch Logs): - Create a temporary dashboard or saved query filtering for "Creating record" OR "Deleting record". - Correlate `external-dns` logs with DNS provider API logs to detect mismatches. **Metrics and Observability** Check metrics exposed by External-DNS (if Prometheus scraping is enabled): Focus on: - Error rate (*_errors_total) - Number of syncs per interval (*_sync_duration_seconds) - Provider API call spikes Example PromQL checks: ```promql rate(external_dns_registry_errors_total[5m]) > 0 rate(external_dns_provider_requests_total{operation="DELETE"}[5m]) ``` ## External Verification Ideally, you should have a set of automated tests Query key DNS records directly: ```sh dig +short myapp.example.com nslookup api.staging.example.com ``` Ensure that A, CNAME, and TXT records remain correct and point to expected endpoints. Additional Tips - Automate upgrade testing with CI/CD pipelines. - Maintain clear CHANGELOGs and migration notes for internal users. - Tag known good versions in Git or Helm values for rollback. - Avoid skipping multiple minor versions when possible. ================================================ FILE: e2e/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: labels: app: demo-app name: demo-app spec: replicas: 1 selector: matchLabels: app: demo-app template: metadata: labels: app: demo-app spec: containers: - image: traefik/whoami:latest # minimal demo app name: demo-app ================================================ FILE: e2e/provider/coredns.yaml ================================================ --- apiVersion: v1 kind: ConfigMap metadata: name: coredns namespace: default data: Corefile: | external.dns:5353 { errors log etcd { stubzones path /skydns endpoint http://etcd-0.etcd:2379 } cache 30 forward . /etc/resolv.conf } --- apiVersion: apps/v1 kind: Deployment metadata: name: coredns namespace: default labels: app: coredns spec: replicas: 1 selector: matchLabels: app: coredns template: metadata: labels: app: coredns spec: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet containers: - name: coredns image: coredns/coredns:1.13.1 args: [ "-conf", "/etc/coredns/Corefile" ] volumeMounts: - name: config-volume mountPath: /etc/coredns ports: - containerPort: 5353 name: dns protocol: UDP - containerPort: 5353 name: dns-tcp protocol: TCP livenessProbe: tcpSocket: port: 5353 initialDelaySeconds: 10 periodSeconds: 10 readinessProbe: tcpSocket: port: 5353 initialDelaySeconds: 5 periodSeconds: 5 volumes: - name: config-volume configMap: name: coredns items: - key: Corefile path: Corefile --- apiVersion: v1 kind: Service metadata: name: coredns namespace: default labels: app: coredns spec: selector: app: coredns ports: - name: dns port: 5353 targetPort: 5353 protocol: UDP - name: dns-tcp port: 5353 targetPort: 5353 protocol: TCP ================================================ FILE: e2e/provider/etcd.yaml ================================================ --- apiVersion: v1 kind: Service metadata: name: etcd namespace: default spec: type: ClusterIP clusterIP: None selector: app: etcd publishNotReadyAddresses: true ports: - name: etcd-client port: 2379 - name: etcd-server port: 2380 - name: etcd-metrics port: 8080 --- apiVersion: apps/v1 kind: StatefulSet metadata: namespace: default name: etcd spec: serviceName: etcd replicas: 1 podManagementPolicy: Parallel updateStrategy: type: RollingUpdate selector: matchLabels: app: etcd template: metadata: labels: app: etcd annotations: serviceName: etcd spec: affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchExpressions: - key: app operator: In values: - etcd topologyKey: "kubernetes.io/hostname" containers: - name: etcd image: quay.io/coreos/etcd:v3.6.0 imagePullPolicy: IfNotPresent ports: - name: etcd-client containerPort: 2379 - name: etcd-server containerPort: 2380 - name: etcd-metrics containerPort: 8080 readinessProbe: httpGet: path: /readyz port: 8080 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 5 successThreshold: 1 failureThreshold: 30 livenessProbe: httpGet: path: /livez port: 8080 initialDelaySeconds: 15 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 env: - name: K8S_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace - name: HOSTNAME valueFrom: fieldRef: fieldPath: metadata.name - name: SERVICE_NAME valueFrom: fieldRef: fieldPath: metadata.annotations['serviceName'] - name: ETCDCTL_ENDPOINTS value: $(HOSTNAME).$(SERVICE_NAME):2379 - name: URI_SCHEME value: "http" command: - /usr/local/bin/etcd args: - --name=$(HOSTNAME) - --data-dir=/data - --wal-dir=/data/wal - --listen-peer-urls=$(URI_SCHEME)://0.0.0.0:2380 - --listen-client-urls=$(URI_SCHEME)://0.0.0.0:2379 - --advertise-client-urls=$(URI_SCHEME)://$(HOSTNAME).$(SERVICE_NAME):2379 - --initial-cluster-state=new - --initial-cluster-token=etcd-$(K8S_NAMESPACE) - --initial-cluster=etcd-0=$(URI_SCHEME)://etcd-0.$(SERVICE_NAME):2380 - --initial-advertise-peer-urls=$(URI_SCHEME)://$(HOSTNAME).$(SERVICE_NAME):2380 - --listen-metrics-urls=http://0.0.0.0:8080 volumeMounts: - name: etcd-data mountPath: /data volumeClaimTemplates: - metadata: name: etcd-data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 1Gi ================================================ FILE: e2e/service.yaml ================================================ apiVersion: v1 kind: Service metadata: labels: app: demo-app name: demo-app annotations: external-dns.alpha.kubernetes.io/hostname: externaldns-e2e.external.dns spec: ports: - port: 80 protocol: TCP targetPort: 8080 selector: app: demo-app clusterIP: None ================================================ FILE: endpoint/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - endpoint ================================================ FILE: endpoint/crypto.go ================================================ /* Copyright 2017 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 endpoint import ( "bytes" "compress/gzip" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "fmt" "io" ) const standardGcmNonceSize = 12 // GenerateNonce creates a random base64-encoded nonce of a fixed size. func GenerateNonce() (string, error) { nonce := make([]byte, standardGcmNonceSize) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } return base64.StdEncoding.EncodeToString(nonce), nil } // EncryptText gzips input data and encrypts it using the supplied AES key. // nonceEncoded must be a base64-encoded nonce of standardGcmNonceSize bytes. func EncryptText(text string, aesKey []byte, nonceEncoded string) (string, error) { if len(nonceEncoded) == 0 { return "", fmt.Errorf("nonce must be provided") } block, err := aes.NewCipher(aesKey) if err != nil { return "", err } gcm, err := cipher.NewGCMWithNonceSize(block, standardGcmNonceSize) if err != nil { return "", err } nonce := make([]byte, standardGcmNonceSize) if _, err = base64.StdEncoding.Decode(nonce, []byte(nonceEncoded)); err != nil { return "", err } data, err := compressData([]byte(text)) if err != nil { return "", err } cipherData := gcm.Seal(nonce, nonce, data, nil) return base64.StdEncoding.EncodeToString(cipherData), nil } // DecryptText decrypts data using the supplied AES encryption key and decompresses it. // Returns the plaintext, the base64-encoded nonce, and any error. func DecryptText(text string, aesKey []byte) (string, string, error) { block, err := aes.NewCipher(aesKey) if err != nil { return "", "", err } gcm, err := cipher.NewGCMWithNonceSize(block, standardGcmNonceSize) if err != nil { return "", "", err } data, err := base64.StdEncoding.DecodeString(text) if err != nil { return "", "", err } if len(data) <= standardGcmNonceSize { return "", "", fmt.Errorf("encrypted data too short: got %d bytes, need more than %d", len(data), standardGcmNonceSize) } nonce, ciphertext := data[:standardGcmNonceSize], data[standardGcmNonceSize:] plaindata, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return "", "", err } plaindata, err = decompressData(plaindata) if err != nil { return "", "", err } return string(plaindata), base64.StdEncoding.EncodeToString(nonce), nil } // decompressData decompresses gzip-compressed data. func decompressData(data []byte) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewBuffer(data)) if err != nil { return nil, err } defer gz.Close() var b bytes.Buffer if _, err = b.ReadFrom(gz); err != nil { return nil, err } return b.Bytes(), nil } // compressData compresses data using gzip to minimize storage in the registry. func compressData(data []byte) ([]byte, error) { var b bytes.Buffer gz, err := gzip.NewWriterLevel(&b, gzip.BestCompression) if err != nil { return nil, err } if _, err = gz.Write(data); err != nil { return nil, err } if err = gz.Flush(); err != nil { return nil, err } if err = gz.Close(); err != nil { return nil, err } return b.Bytes(), nil } ================================================ FILE: endpoint/crypto_test.go ================================================ /* Copyright 2017 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 endpoint import ( "encoding/base64" "io" "testing" "crypto/rand" "github.com/stretchr/testify/require" ) func TestEncrypt(t *testing.T) { // Verify that nil nonce is rejected aesKey := []byte("s%zF`.*'5`9.AhI2!B,.~hmbs^.*TL?;") plaintext := "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum." _, err := EncryptText(plaintext, aesKey, "") require.EqualError(t, err, "nonce must be provided") // Verify that text encryption and decryption works with a generated nonce nonce, err := GenerateNonce() require.NoError(t, err) encryptedtext, err := EncryptText(plaintext, aesKey, nonce) require.NoError(t, err) decryptedtext, _, err := DecryptText(encryptedtext, aesKey) require.NoError(t, err) if plaintext != decryptedtext { t.Errorf("Original plain text %#v differs from the resulting decrypted text %#v", plaintext, decryptedtext) } // Verify that decrypt returns an error and empty data if wrong AES encryption key is used decryptedtext, _, err = DecryptText(encryptedtext, []byte("s'J!jD`].LC?g&Oa11AgTub,j48ts/96")) require.Error(t, err) if decryptedtext != "" { t.Error("Data decryption failed, empty string should be as result") } // Verify that decrypt returns an error and empty data if unencrypted input is supplied decryptedtext, _, err = DecryptText(plaintext, aesKey) require.Error(t, err) if decryptedtext != "" { t.Errorf("Data decryption failed, empty string should be as result") } // Verify that a known encrypted text is decrypted to what is expected encryptedtext = "0Mfzf6wsN8llrfX0ucDZ6nlc2+QiQfKKedjPPLu5atb2I35L9nUZeJcCnuLVW7CVW3K0h94vSuBLdXnMrj8Vcm0M09shxaoF48IcCpD03XtQbKXqk2hPbsW6+JybvplHIQGr16/PcjUSObGmR9yjf38+qEltApkKvrPjsyw43BX4eE10rL0Bln33UJD7/w+zazRDPFlAcbGtkt0ETKHnvyB3/aCddLipvrhjCXj2ZY/ktRF6h716kJRgXU10dCIQHFYU45MIdxI+k10HK3yZqhI2V0Gp2xjrFV/LRQ7/OS9SFee4asPWUYxbCEsnOzp8qc0dCPFSo1dtADzWnUZnsAcbnjtudT4milfLJc5CxDk1v3ykqQ/ajejwHjWQ7b8U6AsTErbezfdcqrb5IzkLgHb5TosnfrdDmNc9GcKfpsrCHbVY8KgNwMVdtwavLv7d9WM6sooUlZ3t0sABGkzagXQmPRvwLnkSOlie5XrnzWo8/8/4UByLga29CaXO" decryptedtext, _, err = DecryptText(encryptedtext, aesKey) require.NoError(t, err) if decryptedtext != plaintext { t.Error("Decryption of text didn't result in expected plaintext result.") } } func TestGenerateNonceSuccess(t *testing.T) { nonce, err := GenerateNonce() require.NoError(t, err) require.NotEmpty(t, nonce) // Test nonce length decodedNonce, err := base64.StdEncoding.DecodeString(nonce) require.NoError(t, err) require.Len(t, decodedNonce, standardGcmNonceSize) } func TestGenerateNonceError(t *testing.T) { // Save the original rand.Reader originalRandReader := rand.Reader defer func() { rand.Reader = originalRandReader }() // Replace rand.Reader with a faulty reader rand.Reader = &faultyReader{} nonce, err := GenerateNonce() require.Error(t, err) require.Empty(t, nonce) } type faultyReader struct{} func (f *faultyReader) Read(_ []byte) (int, error) { return 0, io.ErrUnexpectedEOF } ================================================ FILE: endpoint/domain_filter.go ================================================ /* Copyright 2017 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 endpoint import ( "encoding/json" "errors" "fmt" "regexp" "sort" "strings" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/internal/idna" ) type MatchAllDomainFilters []DomainFilterInterface func (f MatchAllDomainFilters) Match(domain string) bool { for _, filter := range f { if filter == nil { continue } if !filter.Match(domain) { return false } } return true } type DomainFilterInterface interface { Match(domain string) bool } // DomainFilter holds a lists of valid domain names type DomainFilter struct { // Filters define what domains to match Filters []string // exclude define what domains not to match exclude []string // regex defines a regular expression to match the domains regex *regexp.Regexp // regexExclusion defines a regular expression to exclude the domains matched regexExclusion *regexp.Regexp } var _ DomainFilterInterface = &DomainFilter{} // domainFilterSerde is a helper type for serializing and deserializing DomainFilter. type domainFilterSerde struct { Include []string `json:"include,omitempty"` Exclude []string `json:"exclude,omitempty"` RegexInclude string `json:"regexInclude,omitempty"` RegexExclude string `json:"regexExclude,omitempty"` } // prepareFilters provides consistent trimming for filters/exclude params func prepareFilters(filters []string) []string { var fs []string for _, filter := range filters { if domain := normalizeDomain(strings.TrimSpace(filter)); domain != "" { fs = append(fs, domain) } } return fs } // NewDomainFilterWithExclusions returns a new DomainFilter, given a list of matches and exclusions func NewDomainFilterWithExclusions(domainFilters []string, excludeDomains []string) *DomainFilter { return &DomainFilter{Filters: prepareFilters(domainFilters), exclude: prepareFilters(excludeDomains)} } // NewDomainFilter returns a new DomainFilter given a comma separated list of domains func NewDomainFilter(domainFilters []string) *DomainFilter { return &DomainFilter{Filters: prepareFilters(domainFilters)} } // NewRegexDomainFilter returns a new DomainFilter given a regular expression func NewRegexDomainFilter(regexDomainFilter *regexp.Regexp, regexDomainExclusion *regexp.Regexp) *DomainFilter { return &DomainFilter{regex: regexDomainFilter, regexExclusion: regexDomainExclusion} } // NewDomainFilterWithOptions creates a DomainFilter based on the provided parameters. // // Example usage: // df := NewDomainFilterWithOptions( // // WithDomainFilter([]string{"example.com"}), // WithDomainExclude([]string{"test.com"}), // // ) func NewDomainFilterWithOptions(opts ...DomainFilterOption) *DomainFilter { cfg := &domainFilterConfig{} for _, opt := range opts { opt(cfg) } if cfg.isRegexFilter { return NewRegexDomainFilter(cfg.regexInclude, cfg.regexExclude) } return NewDomainFilterWithExclusions(cfg.include, cfg.exclude) } // Match checks whether a domain can be found in the DomainFilter. // RegexFilter takes precedence over Filters func (df *DomainFilter) Match(domain string) bool { if df == nil { return true // nil filter matches everything } if df.regex != nil && df.regex.String() != "" || df.regexExclusion != nil && df.regexExclusion.String() != "" { return matchRegex(df.regex, df.regexExclusion, domain) } return matchFilter(df.Filters, domain, true) && !matchFilter(df.exclude, domain, false) } // matchFilter determines if any `filters` match `domain`. // If no `filters` are provided, behavior depends on `emptyval` // (empty `df.filters` matches everything, while empty `df.exclude` excludes nothing) func matchFilter(filters []string, domain string, emptyval bool) bool { if len(filters) == 0 { return emptyval } strippedDomain := normalizeDomain(domain) for _, filter := range filters { if filter == "" { continue } switch { case strings.HasPrefix(filter, ".") && strings.HasSuffix(strippedDomain, filter): return true case strings.Count(strippedDomain, ".") == strings.Count(filter, ".") && strippedDomain == filter: return true case strings.HasSuffix(strippedDomain, "."+filter): return true } } return false } // matchRegex determines if a domain matches the configured regular expressions in DomainFilter. // The function checks exclusion first, then inclusion: // 1. If negativeRegex is set and matches the domain, return false (excluded) // 2. If regex is set and matches the domain, return true (included) // 3. If regex is not set but negativeRegex is set, return true (not excluded, no inclusion filter) // 4. If regex is set but doesn't match, return false (not included) func matchRegex(regex *regexp.Regexp, negativeRegex *regexp.Regexp, domain string) bool { strippedDomain := normalizeDomain(domain) // First check exclusion - if domain matches exclusion, reject it if negativeRegex != nil && negativeRegex.String() != "" { if negativeRegex.MatchString(strippedDomain) { return false } } // Then check inclusion filter if set if regex != nil && regex.String() != "" { return regex.MatchString(strippedDomain) } // If only exclusion is set (no inclusion filter), accept the domain // since it didn't match the exclusion return true } // IsConfigured returns true if any inclusion or exclusion rules have been specified. func (df *DomainFilter) IsConfigured() bool { if df == nil { return false // nil filter is not configured } if df.regex != nil && df.regex.String() != "" { return true } else if df.regexExclusion != nil && df.regexExclusion.String() != "" { return true } return len(df.Filters) > 0 || len(df.exclude) > 0 } func (df *DomainFilter) MarshalJSON() ([]byte, error) { if df == nil { // compatibility with nil DomainFilter return json.Marshal(domainFilterSerde{ Include: nil, Exclude: nil, }) } if df.regex != nil || df.regexExclusion != nil { var include, exclude string if df.regex != nil { include = df.regex.String() } if df.regexExclusion != nil { exclude = df.regexExclusion.String() } return json.Marshal(domainFilterSerde{ RegexInclude: include, RegexExclude: exclude, }) } sort.Strings(df.Filters) sort.Strings(df.exclude) return json.Marshal(domainFilterSerde{ Include: df.Filters, Exclude: df.exclude, }) } func (df *DomainFilter) UnmarshalJSON(b []byte) error { var deserialized domainFilterSerde err := json.Unmarshal(b, &deserialized) if err != nil { return err } if deserialized.RegexInclude == "" && deserialized.RegexExclude == "" { *df = *NewDomainFilterWithExclusions(deserialized.Include, deserialized.Exclude) return nil } if len(deserialized.Include) > 0 || len(deserialized.Exclude) > 0 { return errors.New("cannot have both domain list and regex") } var include, exclude *regexp.Regexp if deserialized.RegexInclude != "" { include, err = regexp.Compile(deserialized.RegexInclude) if err != nil { return fmt.Errorf("invalid regexInclude: %w", err) } } if deserialized.RegexExclude != "" { exclude, err = regexp.Compile(deserialized.RegexExclude) if err != nil { return fmt.Errorf("invalid regexExclude: %w", err) } } *df = *NewRegexDomainFilter(include, exclude) return nil } func (df *DomainFilter) MatchParent(domain string) bool { if df == nil { return true // nil filter matches everything } if matchFilter(df.exclude, domain, false) { return false } if len(df.Filters) == 0 { return true } strippedDomain := normalizeDomain(domain) for _, filter := range df.Filters { if filter == "" || strings.HasPrefix(filter, ".") { // We don't check parents if the filter is prefixed with "." continue } if strings.HasSuffix(filter, "."+strippedDomain) { return true } } return false } // normalizeDomain converts a domain to a canonical form, so that we can filter on it // it: trim "." suffix, get Unicode version of domain compliant with Section 5 of RFC 5891 func normalizeDomain(domain string) string { s, err := idna.Profile.ToUnicode(strings.TrimSuffix(domain, ".")) if err != nil { log.Warnf(`Got error while parsing domain %s: %v`, domain, err) } return s } type DomainFilterOption func(*domainFilterConfig) type domainFilterConfig struct { include []string exclude []string regexInclude *regexp.Regexp regexExclude *regexp.Regexp isRegexFilter bool } func WithDomainFilter(filters []string) DomainFilterOption { return func(cfg *domainFilterConfig) { cfg.include = prepareFilters(filters) } } func WithDomainExclude(exclude []string) DomainFilterOption { return func(cfg *domainFilterConfig) { cfg.exclude = prepareFilters(exclude) } } func WithRegexDomainFilter(regex *regexp.Regexp) DomainFilterOption { return func(cfg *domainFilterConfig) { cfg.regexInclude = regex if regex != nil && regex.String() != "" { cfg.isRegexFilter = true } } } func WithRegexDomainExclude(regex *regexp.Regexp) DomainFilterOption { return func(cfg *domainFilterConfig) { cfg.regexExclude = regex if regex != nil && regex.String() != "" { cfg.isRegexFilter = true } } } ================================================ FILE: endpoint/domain_filter_test.go ================================================ /* Copyright 2017 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 endpoint import ( "encoding/json" "fmt" "regexp" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type domainFilterTest struct { domainFilter []string exclusions []string domains []string expected bool expectedSerialization map[string][]string } type regexDomainFilterTest struct { regex *regexp.Regexp regexExclusion *regexp.Regexp domains []string expected bool expectedSerialization map[string]string } var domainFilterTests = []domainFilterTest{ { []string{"google.com.", "exaring.de", "inovex.de"}, []string{}, []string{"google.com", "exaring.de", "inovex.de"}, true, map[string][]string{ "include": {"exaring.de", "google.com", "inovex.de"}, }, }, { []string{"google.com.", "exaring.de", "inovex.de"}, []string{}, []string{"google.com", "exaring.de", "inovex.de"}, true, map[string][]string{ "include": {"exaring.de", "google.com", "inovex.de"}, }, }, { []string{"google.com.", "exaring.de.", "inovex.de"}, []string{}, []string{"google.com", "exaring.de", "inovex.de"}, true, map[string][]string{ "include": {"exaring.de", "google.com", "inovex.de"}, }, }, { []string{"foo.org. "}, []string{}, []string{"foo.org"}, true, map[string][]string{ "include": {"foo.org"}, }, }, { []string{" foo.org"}, []string{}, []string{"foo.org"}, true, map[string][]string{ "include": {"foo.org"}, }, }, { []string{"foo.org."}, []string{}, []string{"foo.org"}, true, map[string][]string{ "include": {"foo.org"}, }, }, { []string{"foo.org."}, []string{}, []string{"baz.org"}, false, map[string][]string{ "include": {"foo.org"}, }, }, { []string{"baz.foo.org."}, []string{}, []string{"foo.org"}, false, map[string][]string{ "include": {"baz.foo.org"}, }, }, { []string{"", "foo.org."}, []string{}, []string{"foo.org"}, true, map[string][]string{ "include": {"foo.org"}, }, }, { []string{"", "foo.org."}, []string{}, []string{}, true, map[string][]string{ "include": {"foo.org"}, }, }, { []string{""}, []string{}, []string{"foo.org"}, true, map[string][]string{}, }, { []string{""}, []string{}, []string{}, true, map[string][]string{}, }, { []string{" "}, []string{}, []string{}, true, map[string][]string{}, }, { []string{"bar.sub.example.org"}, []string{}, []string{"foo.bar.sub.example.org"}, true, map[string][]string{ "include": {"bar.sub.example.org"}, }, }, { []string{"example.org"}, []string{}, []string{"anexample.org", "test.anexample.org"}, false, map[string][]string{ "include": {"example.org"}, }, }, { []string{".example.org"}, []string{}, []string{"anexample.org", "test.anexample.org"}, false, map[string][]string{ "include": {".example.org"}, }, }, { []string{".example.org"}, []string{}, []string{"example.org"}, false, map[string][]string{ "include": {".example.org"}, }, }, { []string{".example.org"}, []string{}, []string{"test.example.org"}, true, map[string][]string{ "include": {".example.org"}, }, }, { []string{"anexample.org"}, []string{}, []string{"example.org", "test.example.org"}, false, map[string][]string{ "include": {"anexample.org"}, }, }, { []string{".org"}, []string{}, []string{"example.org", "test.example.org", "foo.test.example.org"}, true, map[string][]string{ "include": {".org"}, }, }, { []string{"example.org"}, []string{"api.example.org"}, []string{"example.org", "test.example.org", "foo.test.example.org"}, true, map[string][]string{ "include": {"example.org"}, "exclude": {"api.example.org"}, }, }, { []string{"example.org"}, []string{"api.example.org"}, []string{"foo.api.example.org", "api.example.org"}, false, map[string][]string{ "include": {"example.org"}, "exclude": {"api.example.org"}, }, }, { []string{" example.org. "}, []string{" .api.example.org "}, []string{"foo.api.example.org", "bar.baz.api.example.org."}, false, map[string][]string{ "include": {"example.org"}, "exclude": {".api.example.org"}, }, }, { []string{"æøå.org"}, []string{"api.æøå.org"}, []string{"foo.api.æøå.org", "api.æøå.org"}, false, map[string][]string{ "include": {"æøå.org"}, "exclude": {"api.æøå.org"}, }, }, { []string{" æøå.org. "}, []string{" .api.æøå.org "}, []string{"foo.api.æøå.org", "bar.baz.api.æøå.org."}, false, map[string][]string{ "include": {"æøå.org"}, "exclude": {".api.æøå.org"}, }, }, { []string{"example.org."}, []string{"api.example.org"}, []string{"dev-api.example.org", "qa-api.example.org"}, true, map[string][]string{ "include": {"example.org"}, "exclude": {"api.example.org"}, }, }, { []string{"example.org."}, []string{"api.example.org"}, []string{"dev.api.example.org", "qa.api.example.org"}, false, map[string][]string{ "include": {"example.org"}, "exclude": {"api.example.org"}, }, }, { []string{"example.org", "api.example.org"}, []string{"internal.api.example.org"}, []string{"foo.api.example.org"}, true, map[string][]string{ "include": {"api.example.org", "example.org"}, "exclude": {"internal.api.example.org"}, }, }, { []string{"example.org", "api.example.org"}, []string{"internal.api.example.org"}, []string{"foo.internal.api.example.org"}, false, map[string][]string{ "include": {"api.example.org", "example.org"}, "exclude": {"internal.api.example.org"}, }, }, { []string{"eXaMPle.ORG", "API.example.ORG"}, []string{"Foo-Bar.Example.Org"}, []string{"FoOoo.Api.Example.Org"}, true, map[string][]string{ "include": {"api.example.org", "example.org"}, "exclude": {"foo-bar.example.org"}, }, }, { []string{"sTOnks📈.ORG", "API.xn--StonkS-u354e.ORG"}, []string{"Foo-Bar.stoNks📈.Org"}, []string{"FoOoo.Api.Stonks📈.Org"}, true, map[string][]string{ "include": {"api.stonks📈.org", "stonks📈.org"}, "exclude": {"foo-bar.stonks📈.org"}, }, }, { []string{"eXaMPle.ORG", "API.example.ORG"}, []string{"api.example.org"}, []string{"foobar.Example.Org"}, true, map[string][]string{ "include": {"api.example.org", "example.org"}, "exclude": {"api.example.org"}, }, }, { []string{"eXaMPle.ORG", "API.example.ORG"}, []string{"api.example.org"}, []string{"foobar.API.Example.Org"}, false, map[string][]string{ "include": {"api.example.org", "example.org"}, "exclude": {"api.example.org"}, }, }, } var regexDomainFilterTests = []regexDomainFilterTest{ { regexp.MustCompile(`\.org$`), regexp.MustCompile(""), []string{"foo.org", "bar.org", "foo.bar.org"}, true, map[string]string{ "regexInclude": "\\.org$", }, }, { regexp.MustCompile(`\.bar\.org$`), regexp.MustCompile(""), []string{"foo.org", "bar.org", "example.com"}, false, map[string]string{ "regexInclude": "\\.bar\\.org$", }, }, { regexp.MustCompile(`(?:foo|bar)\.org$`), regexp.MustCompile(""), []string{"foo.org", "bar.org", "example.foo.org", "example.bar.org", "a.example.foo.org", "a.example.bar.org"}, true, map[string]string{ "regexInclude": "(?:foo|bar)\\.org$", }, }, { regexp.MustCompile("(?:😍|🤩)\\.org$"), regexp.MustCompile(""), []string{"😍.org", "xn--r28h.org", "🤩.org", "example.😍.org", "example.🤩.org", "a.example.xn--r28h.org", "a.example.🤩.org"}, true, map[string]string{ "regexInclude": "(?:😍|🤩)\\.org$", }, }, { regexp.MustCompile("(?:😍|🤩)\\.org$"), regexp.MustCompile("^example\\.(?:😍|🤩)\\.org$"), []string{"example.😍.org", "example.🤩.org"}, false, map[string]string{ "regexInclude": "(?:😍|🤩)\\.org$", "regexExclude": "^example\\.(?:😍|🤩)\\.org$", }, }, { regexp.MustCompile("(?:foo|bar)\\.org$"), regexp.MustCompile("^example\\.(?:foo|bar)\\.org$"), []string{"foo.org", "bar.org", "a.example.foo.org", "a.example.bar.org"}, true, map[string]string{ "regexInclude": `(?:foo|bar)\.org$`, "regexExclude": `^example\.(?:foo|bar)\.org$`, }, }, { regexp.MustCompile(`(?:foo|bar)\.org$`), regexp.MustCompile(`^example\.(?:foo|bar)\.org$`), []string{"example.foo.org", "example.bar.org"}, false, map[string]string{ "regexInclude": "(?:foo|bar)\\.org$", "regexExclude": "^example\\.(?:foo|bar)\\.org$", }, }, { regexp.MustCompile(`(?:foo|bar)\.org$`), regexp.MustCompile(`^example\.(?:foo|bar)\.org$`), []string{"foo.org", "bar.org", "a.example.foo.org", "a.example.bar.org"}, true, map[string]string{ "regexInclude": "(?:foo|bar)\\.org$", "regexExclude": "^example\\.(?:foo|bar)\\.org$", }, }, { // Test case: domain doesn't match include filter, also doesn't match exclusion // Should be REJECTED because it doesn't match the include filter regexp.MustCompile(`foo\.org$`), regexp.MustCompile(`^temp\.`), []string{"bar.org", "example.com", "test.net"}, false, map[string]string{ "regexInclude": `foo\.org$`, "regexExclude": `^temp\.`, }, }, { // Test case: domain matches include filter, doesn't match exclusion // Should be ACCEPTED regexp.MustCompile(`\.prod\.example\.com$`), regexp.MustCompile(`^temp-`), []string{"api.prod.example.com", "web.prod.example.com"}, true, map[string]string{ "regexInclude": `\.prod\.example\.com$`, "regexExclude": `^temp-`, }, }, { // Test case: domain matches both include and exclusion // Exclusion should take precedence - REJECTED regexp.MustCompile(`\.prod\.example\.com$`), regexp.MustCompile(`^temp-`), []string{"temp-api.prod.example.com", "temp-web.prod.example.com"}, false, map[string]string{ "regexInclude": `\.prod\.example\.com$`, "regexExclude": `^temp-`, }, }, { // Test case: domain doesn't match include filter // Should be REJECTED even if exclusion doesn't match regexp.MustCompile(`\.staging\.example\.com$`), regexp.MustCompile(`^internal-`), []string{"api.prod.example.com", "web.dev.example.com", "service.test.org"}, false, map[string]string{ "regexInclude": `\.staging\.example\.com$`, "regexExclude": `^internal-`, }, }, } func TestDomainFilterMatch(t *testing.T) { for i, tt := range domainFilterTests { if len(tt.exclusions) > 0 { t.Logf("NewDomainFilter() doesn't support exclusions - skipping test %+v", tt) continue } t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { domainFilter := NewDomainFilter(tt.domainFilter) assertSerializes(t, domainFilter, tt.expectedSerialization) deserialized := deserialize(t, map[string][]string{ "include": tt.domainFilter, }) for _, domain := range tt.domains { assert.Equal(t, tt.expected, domainFilter.Match(domain), "%v", domain) assert.Equal(t, tt.expected, domainFilter.Match(domain+"."), "%v", domain+".") assert.Equal(t, tt.expected, deserialized.Match(domain), "deserialized %v", domain) assert.Equal(t, tt.expected, deserialized.Match(domain+"."), "deserialized %v", domain+".") } }) } } func TestDomainFilterWithExclusions(t *testing.T) { for i, tt := range domainFilterTests { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { if len(tt.exclusions) == 0 { tt.exclusions = append(tt.exclusions, "") } domainFilter := NewDomainFilterWithOptions( WithDomainFilter(tt.domainFilter), WithDomainExclude(tt.exclusions)) assertSerializes(t, domainFilter, tt.expectedSerialization) deserialized := deserialize(t, map[string][]string{ "include": tt.domainFilter, "exclude": tt.exclusions, }) for _, domain := range tt.domains { assert.Equal(t, tt.expected, domainFilter.Match(domain), "%v", domain) assert.Equal(t, tt.expected, domainFilter.Match(domain+"."), "%v", domain+".") assert.Equal(t, tt.expected, deserialized.Match(domain), "deserialized %v", domain) assert.Equal(t, tt.expected, deserialized.Match(domain+"."), "deserialized %v", domain+".") } }) } } func TestDomainFilterMatchWithEmptyFilter(t *testing.T) { for _, tt := range domainFilterTests { domainFilter := DomainFilter{} for i, domain := range tt.domains { assert.True(t, domainFilter.Match(domain), "should not fail: %v in test-case #%v", domain, i) assert.True(t, domainFilter.Match(domain+"."), "should not fail: %v in test-case #%v", domain+".", i) } } } func TestNewDomainFilterWithExclusionsHandlesEmptyInputs(t *testing.T) { tests := []struct { name string filters []string exclude []string }{ { name: "NilSlices", filters: nil, exclude: nil, }, { name: "EmptySlices", filters: []string{}, exclude: []string{}, }, { name: "WhitespaceOnly", filters: []string{" ", ""}, exclude: []string{"", " "}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { domainFilter := NewDomainFilterWithOptions(WithDomainFilter(tt.filters), WithDomainExclude(tt.exclude)) assert.False(t, domainFilter.IsConfigured()) assert.Empty(t, domainFilter.Filters) assert.Empty(t, domainFilter.exclude) assert.True(t, domainFilter.Match("example.com")) }) } } func TestRegexDomainFilter(t *testing.T) { for i, tt := range regexDomainFilterTests { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { domainFilter := NewDomainFilterWithOptions( WithRegexDomainFilter(tt.regex), WithRegexDomainExclude(tt.regexExclusion)) assertSerializes(t, domainFilter, tt.expectedSerialization) deserialized := deserialize(t, map[string]string{ "regexInclude": tt.regex.String(), "regexExclude": tt.regexExclusion.String(), }) for _, domain := range tt.domains { assert.Equal(t, tt.expected, domainFilter.Match(domain), "%v", domain) assert.Equal(t, tt.expected, domainFilter.Match(domain+"."), "%v", domain+".") assert.Equal(t, tt.expected, deserialized.Match(domain), "deserialized %v", domain) assert.Equal(t, tt.expected, deserialized.Match(domain+"."), "deserialized %v", domain+".") } }) } } func TestPrepareFiltersStripsWhitespaceAndDotSuffix(t *testing.T) { for _, tt := range []struct { input []string output []string }{ { []string{}, nil, }, { []string{""}, nil, }, { []string{" ", " ", ""}, nil, }, { []string{" foo ", " bar. ", "baz.", "xn--bar-zna"}, []string{"foo", "bar", "baz", "øbar"}, }, { []string{"foo.bar", " foo.bar. ", " foo.bar.baz ", " foo.bar.baz. "}, []string{"foo.bar", "foo.bar", "foo.bar.baz", "foo.bar.baz"}, }, } { t.Run("test string", func(t *testing.T) { assert.Equal(t, tt.output, prepareFilters(tt.input)) }) } } func TestMatchFilterReturnsProperEmptyVal(t *testing.T) { emptyFilters := []string{} assert.True(t, matchFilter(emptyFilters, "somedomain.com", true)) assert.False(t, matchFilter(emptyFilters, "somedomain.com", false)) } func TestDomainFilterIsConfigured(t *testing.T) { for i, tt := range []struct { filters []string exclude []string expected bool }{ { []string{""}, []string{""}, false, }, { []string{" "}, []string{" "}, false, }, { []string{"", ""}, []string{""}, false, }, { []string{" . "}, []string{" . "}, false, }, { []string{" notempty.com "}, []string{" "}, true, }, { []string{" notempty.com "}, []string{" thisdoesntmatter.com "}, true, }, { []string{""}, []string{" thisdoesntmatter.com "}, true, }, } { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { df := NewDomainFilterWithExclusions(tt.filters, tt.exclude) assert.Equal(t, tt.expected, df.IsConfigured()) }) } } func TestRegexDomainFilterIsConfigured(t *testing.T) { for i, tt := range []struct { regex string regexExclude string expected bool }{ { "", "", false, }, { "(?:foo|bar)\\.org$", "", true, }, { "", "\\.org$", true, }, { "(?:foo|bar)\\.org$", "\\.org$", true, }, } { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { df := NewDomainFilterWithOptions( WithRegexDomainFilter(regexp.MustCompile(tt.regex)), WithRegexDomainExclude(regexp.MustCompile(tt.regexExclude))) assert.Equal(t, tt.expected, df.IsConfigured()) }) } } func TestDomainFilterDeserializeError(t *testing.T) { for _, tt := range []struct { name string serialized map[string]any expectedError string }{ { name: "invalid json", serialized: map[string]any{ "include": 3, }, expectedError: "json: cannot unmarshal number into Go struct field domainFilterSerde.include of type []string", }, { name: "include and regexInclude", serialized: map[string]any{ "include": []string{"example.com"}, "regexInclude": "example.com", }, expectedError: "cannot have both domain list and regex", }, { name: "exclude and regexInclude", serialized: map[string]any{ "exclude": []string{"example.com"}, "regexInclude": "example.com", }, expectedError: "cannot have both domain list and regex", }, { name: "include and regexExclude", serialized: map[string]any{ "include": []string{"example.com"}, "regexExclude": "example.com", }, expectedError: "cannot have both domain list and regex", }, { name: "exclude and regexExclude", serialized: map[string]any{ "exclude": []string{"example.com"}, "regexExclude": "example.com", }, expectedError: "cannot have both domain list and regex", }, { name: "invalid regexInclude", serialized: map[string]any{ "regexInclude": "*", }, expectedError: "invalid regexInclude: error parsing regexp: missing argument to repetition operator: `*`", }, { name: "invalid regexExclude", serialized: map[string]any{ "regexExclude": "*", }, expectedError: "invalid regexExclude: error parsing regexp: missing argument to repetition operator: `*`", }, } { t.Run(tt.name, func(t *testing.T) { var deserialized DomainFilter toJson, _ := json.Marshal(tt.serialized) err := json.Unmarshal(toJson, &deserialized) assert.EqualError(t, err, tt.expectedError) }) } } func assertSerializes[T any](t *testing.T, domainFilter *DomainFilter, expectedSerialization map[string]T) { serialized, err := json.Marshal(domainFilter) assert.NoError(t, err, "serializing") expected, err := json.Marshal(expectedSerialization) require.NoError(t, err) assert.JSONEq(t, string(expected), string(serialized), "json serialization") } func deserialize[T any](t *testing.T, serialized map[string]T) *DomainFilter { inJson, err := json.Marshal(serialized) require.NoError(t, err) var deserialized DomainFilter err = json.Unmarshal(inJson, &deserialized) assert.NoError(t, err, "deserializing") return &deserialized } func TestDomainFilterMatchParent(t *testing.T) { parentMatchTests := []domainFilterTest{ { []string{"a.example.com."}, []string{}, []string{"example.com"}, true, map[string][]string{ "include": {"a.example.com"}, }, }, { []string{" a.example.com "}, []string{}, []string{"example.com"}, true, map[string][]string{ "include": {"a.example.com"}, }, }, { []string{""}, []string{}, []string{"example.com"}, true, map[string][]string{}, }, { []string{".a.example.com."}, []string{}, []string{"example.com"}, false, map[string][]string{ "include": {".a.example.com"}, }, }, { []string{"a.example.com.", "b.example.com"}, []string{}, []string{"example.com"}, true, map[string][]string{ "include": {"a.example.com", "b.example.com"}, }, }, { []string{"a.xn--c1yn36f.æøå.", "b.點看.xn--5cab8c", "c.點看.æøå"}, []string{}, []string{"xn--c1yn36f.xn--5cab8c"}, true, map[string][]string{ "include": {"a.點看.æøå", "b.點看.æøå", "c.點看.æøå"}, }, }, { []string{"punycode.xn--c1yn36f.local", "å.點看.local.", "ø.點看.local"}, []string{}, []string{"點看.local"}, true, map[string][]string{ "include": {"punycode.點看.local", "å.點看.local", "ø.點看.local"}, }, }, { []string{"a.example.com"}, []string{}, []string{"b.example.com"}, false, map[string][]string{ "include": {"a.example.com"}, }, }, { []string{"example.com"}, []string{}, []string{"example.com"}, false, map[string][]string{ "include": {"example.com"}, }, }, { []string{"example.com"}, []string{}, []string{"anexample.com"}, false, map[string][]string{ "include": {"example.com"}, }, }, { []string{""}, []string{}, []string{""}, true, map[string][]string{}, }, } for i, tt := range parentMatchTests { t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { domainFilter := NewDomainFilterWithOptions( WithDomainFilter(tt.domainFilter), WithDomainExclude(tt.exclusions)) assertSerializes(t, domainFilter, tt.expectedSerialization) deserialized := deserialize(t, map[string][]string{ "include": tt.domainFilter, "exclude": tt.exclusions, }) for _, domain := range tt.domains { assert.Equal(t, tt.expected, domainFilter.MatchParent(domain), "%v", domain) assert.Equal(t, tt.expected, domainFilter.MatchParent(domain+"."), "%v", domain+".") assert.Equal(t, tt.expected, deserialized.MatchParent(domain), "deserialized %v", domain) assert.Equal(t, tt.expected, deserialized.MatchParent(domain+"."), "deserialized %v", domain+".") } }) } } func TestSimpleDomainFilterWithExclusion(t *testing.T) { test := []struct { domainFilter []string exclusionFilter []string domains []string want []string }{ { domainFilter: []string{"ex.com"}, exclusionFilter: []string{"subdomain.ex.com"}, domains: []string{"subdomain.ex.com", "ex.com", "subdomain.ex.com.", ".subdomain.ex.com", "one.subdomain.ex.com", "ex.com."}, want: []string{"ex.com", "ex.com."}, }, { domainFilter: []string{"ex.com"}, exclusionFilter: []string{}, domains: []string{"subdomain.ex.com", "ex.com", "subdomain.ex.com.", ".subdomain.ex.com", "one.subdomain.ex.com", "ex.com."}, want: []string{"subdomain.ex.com", "ex.com", "subdomain.ex.com.", ".subdomain.ex.com", "one.subdomain.ex.com", "ex.com."}, }, { domainFilter: []string{"ex.com"}, exclusionFilter: []string{"one.subdomain.ex.com"}, domains: []string{"subdomain.ex.com", "ex.com", "subdomain.ex.com.", ".subdomain.ex.com", "one.subdomain.ex.com", "ex.com."}, want: []string{"subdomain.ex.com", "ex.com", "subdomain.ex.com.", ".subdomain.ex.com", "ex.com."}, }, { domainFilter: []string{"ex.com"}, exclusionFilter: []string{".ex.com"}, domains: []string{"subdomain.ex.com", "ex.com", "subdomain.ex.com.", ".subdomain.ex.com", "one.subdomain.ex.com", "ex.com."}, want: []string{"ex.com", "ex.com."}, }, } for _, tt := range test { t.Run(fmt.Sprintf("include:%s-exclude:%s", strings.Join(tt.domainFilter, "_"), strings.Join(tt.exclusionFilter, "_")), func(t *testing.T) { domainFilter := NewDomainFilterWithOptions( WithDomainFilter(tt.domainFilter), WithDomainExclude(tt.exclusionFilter)) var got []string for _, domain := range tt.domains { if domainFilter.Match(domain) { got = append(got, domain) } } assert.Len(t, tt.want, len(got)) assert.Equal(t, tt.want, got) }) } } func TestDomainFilterNormalizeDomain(t *testing.T) { records := []struct { dnsName string expect string }{ { "3AAAA.FOO.BAR.COM", "3aaaa.foo.bar.com", }, { "example.foo.com.", "example.foo.com", }, { "example123.foo.com", "example123.foo.com", }, { "foo.com.", "foo.com", }, { "foo123.COM", "foo123.com", }, { "my-exaMple3.FOO.BAR.COM", "my-example3.foo.bar.com", }, { "my-example1214.FOO-1235.BAR-foo.COM", "my-example1214.foo-1235.bar-foo.com", }, { "my-example-my-example-1214.FOO-1235.BAR-foo.COM", "my-example-my-example-1214.foo-1235.bar-foo.com", }, { "xn--c1yn36f.org.", "點看.org", }, { "xn--nordic--w1a.xn--xn--kItty-pd34d-hn01b3542b.com", "nordic-ø.xn--kitty-點看pd34d.com", }, { "xn--nordic--w1a.xn--kItty-pd34d.com", "nordic-ø.kitty😸.com", }, { "nordic-ø.kitty😸.COM", "nordic-ø.kitty😸.com", }, { "xn--nordic--w1a.kiTTy😸.com.", "nordic-ø.kitty😸.com", }, } for _, r := range records { gotName := normalizeDomain(r.dnsName) assert.Equal(t, r.expect, gotName) } } func TestMatchTargetFilterReturnsProperEmptyVal(t *testing.T) { var emptyFilters []string assert.True(t, matchFilter(emptyFilters, "sometarget.com", true)) assert.False(t, matchFilter(emptyFilters, "sometarget.com", false)) } func TestNewDomainFilterFromConfig(t *testing.T) { tests := []struct { name string domainFilter []string domainExclude []string regexDomainFilter *regexp.Regexp regexDomainExclusion *regexp.Regexp expectedDomainFilter *DomainFilter isConfigured bool matchDomain string expectMatch bool }{ { name: "RegexDomainFilter with non regex filters ignored", regexDomainFilter: regexp.MustCompile(`example\.com`), regexDomainExclusion: regexp.MustCompile(`excluded\.example\.com`), domainFilter: []string{"example.com"}, domainExclude: []string{"excluded.example.com"}, expectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`example\.com`), regexp.MustCompile(`excluded\.example\.com`)), isConfigured: true, }, { name: "RegexDomainWithoutExclusionFilter and domainExclude is ignored", regexDomainFilter: regexp.MustCompile(`example\.com`), domainExclude: []string{"excluded.example.com"}, expectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`example\.com`), nil), isConfigured: true, }, { name: "DomainFilterWithExclusions", regexDomainFilter: regexp.MustCompile(``), domainFilter: []string{"example.com"}, domainExclude: []string{"excluded.example.com"}, expectedDomainFilter: NewDomainFilterWithExclusions([]string{"example.com"}, []string{"excluded.example.com"}), isConfigured: true, }, { name: "DomainFilterWithExclusionsOnly", domainExclude: []string{"excluded.example.com"}, expectedDomainFilter: NewDomainFilterWithExclusions([]string{}, []string{"excluded.example.com"}), isConfigured: true, }, { name: "EmptyDomainFilter", domainFilter: []string{}, domainExclude: []string{}, expectedDomainFilter: NewDomainFilterWithExclusions([]string{}, []string{}), isConfigured: false, }, { name: "RegexDomainExclusionWithoutRegexFilter", regexDomainExclusion: regexp.MustCompile(`test-v1\.3\.example-test\.in`), expectedDomainFilter: NewRegexDomainFilter(nil, regexp.MustCompile(`test-v1\.3\.example-test\.in`)), isConfigured: true, matchDomain: "test-v1.3.example-test.in", expectMatch: false, }, { name: "RegexDomainFilterWithMultipleDomains", regexDomainFilter: regexp.MustCompile(`(example\.com|test\.org)`), expectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`(example\.com|test\.org)`), nil), isConfigured: true, matchDomain: "api.example.com", expectMatch: true, }, { name: "RegexDomainFilterWithWildcardPattern", regexDomainFilter: regexp.MustCompile(`.*\.staging\..*`), expectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`.*\.staging\..*`), nil), isConfigured: true, matchDomain: "app.staging.example.com", expectMatch: true, }, { name: "RegexDomainExclusionWithComplexPattern", regexDomainExclusion: regexp.MustCompile(`^(internal|private)-.*\.example\.com$`), expectedDomainFilter: NewRegexDomainFilter(nil, regexp.MustCompile(`^(internal|private)-.*\.example\.com$`)), isConfigured: true, matchDomain: "internal-service.example.com", expectMatch: false, }, { name: "RegexFilterAndExclusionBothPresent", regexDomainFilter: regexp.MustCompile(`.*\.prod\..*`), regexDomainExclusion: regexp.MustCompile(`temp-.*\.prod\..*`), expectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`.*\.prod\..*`), regexp.MustCompile(`temp-.*\.prod\..*`)), isConfigured: true, matchDomain: "temp-api.prod.example.com", expectMatch: false, }, { name: "RegexWithEscapedSpecialChars", regexDomainFilter: regexp.MustCompile(`test\-api\.v\d+\.example\.com`), expectedDomainFilter: NewRegexDomainFilter(regexp.MustCompile(`test\-api\.v\d+\.example\.com`), nil), isConfigured: true, matchDomain: "test-api.v2.example.com", expectMatch: true, }, { name: "RegexExclusionWithNumericPattern", regexDomainExclusion: regexp.MustCompile(`\d{3,}-temp\..*`), expectedDomainFilter: NewRegexDomainFilter(nil, regexp.MustCompile(`\d{3,}-temp\..*`)), isConfigured: true, matchDomain: "123-temp.example.com", expectMatch: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { filter := NewDomainFilterWithOptions( WithDomainFilter(tt.domainFilter), WithDomainExclude(tt.domainExclude), WithRegexDomainFilter(tt.regexDomainFilter), WithRegexDomainExclude(tt.regexDomainExclusion)) assert.Equal(t, tt.isConfigured, filter.IsConfigured()) assert.Equal(t, tt.expectedDomainFilter, filter) if tt.matchDomain != "" { assert.Equal(t, tt.expectMatch, filter.Match(tt.matchDomain)) } }) } } func TestRegexDomainFilterZoneNames(t *testing.T) { const ( rootZone = "test.com" usEast1Zone = "us-east-1.test.com" euCentral1Zone = "eu-central-1.test.com" globalZone = "global.test.com" wwwUsEast1 = "www.us-east-1.test.com" wwwEuCentral1 = "www.eu-central-1.test.com" wwwRoot = "www.test.com" ) tests := []struct { name string regex string domains []string assertions func(t *testing.T, domain string, matched bool) }{ { name: `^[\w-]+\.us-east-1\.test\.com$: + requires label prefix, matches records under us-east-1.test.com`, regex: `^[\w-]+\.us-east-1\.test\.com$`, domains: []string{wwwUsEast1}, assertions: func(t *testing.T, domain string, matched bool) { assert.True(t, matched, domain) }, }, { name: `^[\w-]+\.us-east-1\.test\.com$: + excludes zone apex and all non-us-east-1 domains`, regex: `^[\w-]+\.us-east-1\.test\.com$`, domains: []string{usEast1Zone, rootZone, euCentral1Zone, globalZone, wwwRoot, wwwEuCentral1}, assertions: func(t *testing.T, domain string, matched bool) { assert.False(t, matched, domain) }, }, { name: `^([\w-]+\.)*us-east-1\.test\.com$: * makes label prefix optional, matches zone apex and records`, regex: `^([\w-]+\.)*us-east-1\.test\.com$`, domains: []string{usEast1Zone, wwwUsEast1}, assertions: func(t *testing.T, domain string, matched bool) { assert.True(t, matched, domain) }, }, { name: `^([\w-]+\.)*us-east-1\.test\.com$: does not match root zone, other regions, or global`, regex: `^([\w-]+\.)*us-east-1\.test\.com$`, domains: []string{rootZone, euCentral1Zone, globalZone, wwwRoot, wwwEuCentral1}, assertions: func(t *testing.T, domain string, matched bool) { assert.False(t, matched, domain) }, }, { name: `^[\w-]+\.(us-east-1|eu-central-1)\.test\.com$: + matches records under both regions`, regex: `^[\w-]+\.(us-east-1|eu-central-1)\.test\.com$`, domains: []string{wwwUsEast1, wwwEuCentral1}, assertions: func(t *testing.T, domain string, matched bool) { assert.True(t, matched, domain) }, }, { name: `^[\w-]+\.(us-east-1|eu-central-1)\.test\.com$: + excludes both regional zone apexes`, regex: `^[\w-]+\.(us-east-1|eu-central-1)\.test\.com$`, domains: []string{usEast1Zone, euCentral1Zone, rootZone, globalZone, wwwRoot}, assertions: func(t *testing.T, domain string, matched bool) { assert.False(t, matched, domain) }, }, { name: `^([\w-]+\.)*(?:us-east-1|eu-central-1)\.test\.com$: * matches zone apexes and records for both regions`, regex: `^([\w-]+\.)*(?:us-east-1|eu-central-1)\.test\.com$`, domains: []string{usEast1Zone, euCentral1Zone, wwwUsEast1, wwwEuCentral1}, assertions: func(t *testing.T, domain string, matched bool) { assert.True(t, matched, domain) }, }, { name: `^([\w-]+\.)*(?:us-east-1|eu-central-1)\.test\.com$: does not match root zone, global, or root record`, regex: `^([\w-]+\.)*(?:us-east-1|eu-central-1)\.test\.com$`, domains: []string{rootZone, globalZone, wwwRoot}, assertions: func(t *testing.T, domain string, matched bool) { assert.False(t, matched, domain) }, }, { name: `^www\.us-east-1\.test\.com$: exact record match, matches www.us-east-1.test.com only`, regex: `^www\.us-east-1\.test\.com$`, domains: []string{wwwUsEast1}, assertions: func(t *testing.T, domain string, matched bool) { assert.True(t, matched, domain) }, }, { name: `^www\.us-east-1\.test\.com$: does not match zone apexes or other records`, regex: `^www\.us-east-1\.test\.com$`, domains: []string{rootZone, usEast1Zone, euCentral1Zone, wwwRoot, wwwEuCentral1}, assertions: func(t *testing.T, domain string, matched bool) { assert.False(t, matched, domain) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { df := NewRegexDomainFilter(regexp.MustCompile(tt.regex), nil) for _, domain := range tt.domains { tt.assertions(t, domain, df.Match(domain)) tt.assertions(t, domain+".", df.Match(domain+".")) } }) } } ================================================ FILE: endpoint/endpoint.go ================================================ /* Copyright 2017 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 endpoint import ( "cmp" "fmt" "net/netip" "slices" "sort" "strconv" "strings" "github.com/miekg/dns" log "github.com/sirupsen/logrus" "k8s.io/utils/set" "sigs.k8s.io/external-dns/pkg/events" ) const ( // RecordTypeA is a RecordType enum value RecordTypeA = "A" // RecordTypeAAAA is a RecordType enum value RecordTypeAAAA = "AAAA" // RecordTypeCNAME is a RecordType enum value RecordTypeCNAME = "CNAME" // RecordTypeTXT is a RecordType enum value RecordTypeTXT = "TXT" // RecordTypeSRV is a RecordType enum value RecordTypeSRV = "SRV" // RecordTypeNS is a RecordType enum value RecordTypeNS = "NS" // RecordTypePTR is a RecordType enum value RecordTypePTR = "PTR" // RecordTypeMX is a RecordType enum value RecordTypeMX = "MX" // RecordTypeNAPTR is a RecordType enum value RecordTypeNAPTR = "NAPTR" // TODO: review source/annotations package to consolidate alias key definitions; // currently duplicated here to avoid circular dependency. providerSpecificAlias = "alias" // ProviderSpecificRecordType is the provider-specific property name used to // request a particular DNS record type (e.g. "ptr") on an endpoint. ProviderSpecificRecordType = "record-type" ) var ( KnownRecordTypes = []string{ RecordTypeA, RecordTypeAAAA, RecordTypeCNAME, RecordTypeTXT, RecordTypeSRV, RecordTypeNS, RecordTypePTR, RecordTypeMX, RecordTypeNAPTR, } ) // TTL is a structure defining the TTL of a DNS record type TTL int64 // IsConfigured returns true if TTL is configured, false otherwise func (ttl TTL) IsConfigured() bool { return ttl > 0 } // Targets is a representation of a list of targets for an endpoint. type Targets []string // MXTarget represents a single MX (Mail Exchange) record target, including its priority and host. type MXTarget struct { priority uint16 host string } // NewTargets is a convenience method to create a new Targets object from a vararg of strings. // Returns a new Targets slice with duplicates removed and elements sorted in order. func NewTargets(target ...string) Targets { return set.New(target...).SortedList() } func (t Targets) String() string { return strings.Join(t, ";") } func (t Targets) Len() int { return len(t) } func (t Targets) Less(i, j int) bool { ipi, err := netip.ParseAddr(t[i]) if err != nil { return t[i] < t[j] } ipj, err := netip.ParseAddr(t[j]) if err != nil { return t[i] < t[j] } return ipi.String() < ipj.String() } func (t Targets) Swap(i, j int) { t[i], t[j] = t[j], t[i] } // Same compares two Targets and returns true if they are identical (case-insensitive) func (t Targets) Same(o Targets) bool { if len(t) != len(o) { return false } sort.Stable(t) sort.Stable(o) for i, e := range t { if !strings.EqualFold(e, o[i]) { // IPv6 can be shortened, so it should be parsed for equality checking ipA, err := netip.ParseAddr(e) if err != nil { log.WithFields(log.Fields{ "targets": t, "comparisonTargets": o, }).Debugf("Couldn't parse %s as an IP address: %v", e, err) } ipB, err := netip.ParseAddr(o[i]) if err != nil { log.WithFields(log.Fields{ "targets": t, "comparisonTargets": o, }).Debugf("Couldn't parse %s as an IP address: %v", e, err) } // IPv6 Address Shortener == IPv6 Address Expander if ipA.IsValid() && ipB.IsValid() { return ipA.String() == ipB.String() } return false } } return true } // IsLess should fulfill the requirement to compare two targets and choose the 'lesser' one. // In the past target was a simple string so simple string comparison could be used. Now we define 'less' // as either being the shorter list of targets or where the first entry is less. // FIXME We really need to define under which circumstances a list Targets is considered 'less' // than another. func (t Targets) IsLess(o Targets) bool { if len(t) < len(o) { return true } if len(t) > len(o) { return false } sort.Sort(t) sort.Sort(o) for i, e := range t { if e != o[i] { // Explicitly prefers IP addresses (e.g. A records) over FQDNs (e.g. CNAMEs). // This prevents behavior like `1-2-3-4.example.com` being "less" than `1.2.3.4` when doing lexicographical string comparison. ipA, err := netip.ParseAddr(e) if err != nil { // Ignoring parsing errors is fine due to the empty netip.Addr{} type being an invalid IP, // which is checked by IsValid() below. However, still log them in case a provider is experiencing // non-obvious issues with the records being created. log.WithFields(log.Fields{ "targets": t, "comparisonTargets": o, }).Debugf("Couldn't parse %s as an IP address: %v", e, err) } ipB, err := netip.ParseAddr(o[i]) if err != nil { log.WithFields(log.Fields{ "targets": t, "comparisonTargets": o, }).Debugf("Couldn't parse %s as an IP address: %v", e, err) } // If both targets are valid IP addresses, use the built-in Less() function to do the comparison. // If one is a valid IP and the other is not, prefer the IP address (consider it "less"). // If neither is a valid IP, use lexicographical string comparison to determine which string sorts first alphabetically. switch { case ipA.IsValid() && ipB.IsValid(): return ipA.Less(ipB) case ipA.IsValid() && !ipB.IsValid(): return true case !ipA.IsValid() && ipB.IsValid(): return false default: return e < o[i] } } } return false } // ProviderSpecificProperty holds the name and value of a configuration which is specific to individual DNS providers type ProviderSpecificProperty struct { Name string `json:"name,omitempty"` Value string `json:"value,omitempty"` } // ProviderSpecific holds configuration which is specific to individual DNS providers type ProviderSpecific []ProviderSpecificProperty // EndpointKey is the type of a map key for separating endpoints or targets. type EndpointKey struct { DNSName string RecordType string SetIdentifier string RecordTTL TTL Target string } type ObjectRef = events.ObjectReference // Endpoint is a high-level way of a connection between a service and an IP // +kubebuilder:object:generate=true type Endpoint struct { // The hostname of the DNS record DNSName string `json:"dnsName,omitempty"` // The targets the DNS record points to Targets Targets `json:"targets,omitempty"` // RecordType type of record, e.g. CNAME, A, AAAA, SRV, TXT etc RecordType string `json:"recordType,omitempty"` // Identifier to distinguish multiple records with the same name and type (e.g. Route53 records with routing policies other than 'simple') SetIdentifier string `json:"setIdentifier,omitempty"` // TTL for the record RecordTTL TTL `json:"recordTTL,omitempty"` // Labels stores labels defined for the Endpoint // +optional Labels Labels `json:"labels,omitempty"` // ProviderSpecific stores provider specific config // +optional ProviderSpecific ProviderSpecific `json:"providerSpecific,omitempty"` // refObject stores reference object // TODO: should be an array, as endpoints merged from multiple sources may have multiple ref objects // +optional refObject *ObjectRef `json:"-"` } // NewEndpoint initialization method to be used to create an endpoint func NewEndpoint(dnsName, recordType string, targets ...string) *Endpoint { return NewEndpointWithTTL(dnsName, recordType, TTL(0), targets...) } // NewEndpointWithTTL initialization method to be used to create an endpoint with a TTL struct func NewEndpointWithTTL(dnsName, recordType string, ttl TTL, targets ...string) *Endpoint { cleanTargets := make([]string, len(targets)) for idx, target := range targets { // Only trim trailing dots for domain name record types, not for TXT or NAPTR records // TXT records can contain arbitrary text including multiple dots // SRV can contain dots in their target part (RFC2782) switch recordType { case RecordTypeTXT, RecordTypeNAPTR, RecordTypeSRV: cleanTargets[idx] = target default: cleanTargets[idx] = strings.TrimSuffix(target, ".") } } for label := range strings.SplitSeq(dnsName, ".") { if len(label) > 63 { log.Errorf("label %s in %s is longer than 63 characters. Cannot create endpoint", label, dnsName) return nil } } return &Endpoint{ DNSName: strings.TrimSuffix(dnsName, "."), Targets: cleanTargets, RecordType: recordType, Labels: NewLabels(), RecordTTL: ttl, } } // WithSetIdentifier applies the given set identifier to the endpoint. func (e *Endpoint) WithSetIdentifier(setIdentifier string) *Endpoint { e.SetIdentifier = setIdentifier return e } // WithProviderSpecific attaches a key/value pair to the Endpoint and returns the Endpoint. // This can be used to pass additional data through the stages of ExternalDNS's Endpoint processing. // The assumption is that most of the time this will be provider specific metadata that doesn't // warrant its own field on the Endpoint object itself. It differs from Labels in the fact that it's // not persisted in the Registry but only kept in memory during a single record synchronization. func (e *Endpoint) WithProviderSpecific(key, value string) *Endpoint { e.SetProviderSpecificProperty(key, value) return e } // GetProviderSpecificProperty returns the value of a ProviderSpecificProperty if the property exists. func (e *Endpoint) GetProviderSpecificProperty(key string) (string, bool) { if len(e.ProviderSpecific) == 0 { return "", false } for _, providerSpecific := range e.ProviderSpecific { if providerSpecific.Name == key { return providerSpecific.Value, true } } return "", false } // GetBoolProviderSpecificProperty returns a boolean provider-specific property value. func (e *Endpoint) GetBoolProviderSpecificProperty(key string) (bool, bool) { prop, ok := e.GetProviderSpecificProperty(key) if !ok { return false, false } switch prop { case "true": return true, true case "false": return false, true default: return false, true } } // SetProviderSpecificProperty sets the value of a ProviderSpecificProperty. func (e *Endpoint) SetProviderSpecificProperty(key string, value string) { if len(e.ProviderSpecific) == 0 { e.ProviderSpecific = append(e.ProviderSpecific, ProviderSpecificProperty{ Name: key, Value: value, }) return } for i, providerSpecific := range e.ProviderSpecific { if providerSpecific.Name == key { e.ProviderSpecific[i] = ProviderSpecificProperty{ Name: key, Value: value, } return } } e.ProviderSpecific = append(e.ProviderSpecific, ProviderSpecificProperty{Name: key, Value: value}) } // DeleteProviderSpecificProperty deletes any ProviderSpecificProperty of the specified name. func (e *Endpoint) DeleteProviderSpecificProperty(key string) { if len(e.ProviderSpecific) == 0 { return } for i, providerSpecific := range e.ProviderSpecific { if providerSpecific.Name == key { e.ProviderSpecific = append(e.ProviderSpecific[:i], e.ProviderSpecific[i+1:]...) return } } } // RetainProviderProperties retains only properties whose name is prefixed with // "provider/" (e.g. "aws/evaluate-target-health" for provider "aws"). // Properties belonging to other providers are dropped. // Properties with no provider prefix (e.g. "alias") are provider-agnostic and always retained. // TODO: cloudflare does not follow the "provider/" prefix convention — its properties use the // annotation form "external-dns.alpha.kubernetes.io/cloudflare-*", so filtering is skipped for // cloudflare and all properties are retained (only sorted). This should be removed once cloudflare // adopts the standard prefix convention. func (e *Endpoint) RetainProviderProperties(provider string) { if len(e.ProviderSpecific) == 0 { return } if provider != "" && provider != "cloudflare" { prefix := provider + "/" e.ProviderSpecific = slices.DeleteFunc(e.ProviderSpecific, func(prop ProviderSpecificProperty) bool { return strings.Contains(prop.Name, "/") && !strings.HasPrefix(prop.Name, prefix) }) } slices.SortFunc(e.ProviderSpecific, func(a, b ProviderSpecificProperty) int { return cmp.Compare(a.Name, b.Name) }) } // WithLabel adds or updates a label for the Endpoint. // // Example usage: // // ep.WithLabel("owner", "user123") func (e *Endpoint) WithLabel(key, value string) *Endpoint { if e.Labels == nil { e.Labels = NewLabels() } e.Labels[key] = value return e } // WithRefObject sets the reference object for the Endpoint and returns the Endpoint. // This can be used to associate the Endpoint with a specific Kubernetes object. func (e *Endpoint) WithRefObject(obj *events.ObjectReference) *Endpoint { e.refObject = obj return e } func (e *Endpoint) RefObject() *events.ObjectReference { return e.refObject } // Key returns the EndpointKey of the Endpoint. func (e *Endpoint) Key() EndpointKey { return EndpointKey{ DNSName: e.DNSName, RecordType: e.RecordType, SetIdentifier: e.SetIdentifier, } } // IsOwnedBy returns true if the endpoint owner label matches the given ownerID, false otherwise func (e *Endpoint) IsOwnedBy(ownerID string) bool { endpointOwner, ok := e.Labels[OwnerLabelKey] return ok && endpointOwner == ownerID } // GetNakedDomain returns the parent domain of the DNS name (without the first label). // For example, "www.example.com" returns "example.com". // For apex/two-label names like "example.com", the full name is returned unchanged. func (e *Endpoint) GetNakedDomain() string { if e.DNSName == "" { return "" } parts := strings.SplitN(e.DNSName, ".", 2) if len(parts) < 2 || !strings.Contains(parts[1], ".") { return e.DNSName } return parts[1] } // NewPTREndpoint creates a PTR endpoint from a forward IP target and one or more hostnames. // It computes the reverse DNS name (in-addr.arpa / ip6.arpa) from the target IP. func NewPTREndpoint(target string, ttl TTL, hostnames ...string) (*Endpoint, error) { revAddr, err := dns.ReverseAddr(target) if err != nil { return nil, fmt.Errorf("failed to compute reverse address for %s: %w", target, err) } ptrName := strings.TrimSuffix(revAddr, ".") return NewEndpointWithTTL(ptrName, RecordTypePTR, ttl, hostnames...), nil } func (e *Endpoint) String() string { return fmt.Sprintf("%s %d IN %s %s %s %s", e.DNSName, e.RecordTTL, e.RecordType, e.SetIdentifier, e.Targets, e.ProviderSpecific) } func (e *Endpoint) Describe() string { return fmt.Sprintf("record:%s, owner:%s, type:%s, targets:%s", e.DNSName, e.SetIdentifier, e.RecordType, strings.Join(e.Targets, ", ")) } // FilterEndpointsByOwnerID Apply filter to slice of endpoints and return new filtered slice that includes // only endpoints that match. func FilterEndpointsByOwnerID(ownerID string, eps []*Endpoint) []*Endpoint { filtered := []*Endpoint{} for _, ep := range eps { endpointOwner, ok := ep.Labels[OwnerLabelKey] switch { case !ok: log.Debugf(`Skipping endpoint %v because of missing owner label (required: "%s")`, ep, ownerID) case endpointOwner != ownerID: log.Debugf(`Skipping endpoint %v because owner id does not match (found: "%s", required: "%s")`, ep, endpointOwner, ownerID) default: filtered = append(filtered, ep) } } return filtered } // RemoveDuplicates returns a slice holding the unique endpoints. // This function doesn't contemplate the Targets of an Endpoint // as part of the primary Key func RemoveDuplicates(endpoints []*Endpoint) []*Endpoint { visited := make(map[EndpointKey]struct{}) result := []*Endpoint{} for _, ep := range endpoints { key := ep.Key() if _, found := visited[key]; !found { result = append(result, ep) visited[key] = struct{}{} } else { log.Debugf(`Skipping duplicated endpoint: %v`, ep) } } return result } // RequestedRecordType returns the value of the "record-type" provider-specific // property, following the same pattern as the alias accessor. func (e *Endpoint) RequestedRecordType() (string, bool) { return e.GetProviderSpecificProperty(ProviderSpecificRecordType) } // TODO: rename to Validate // CheckEndpoint Check if endpoint is properly formatted according to RFC standards func (e *Endpoint) CheckEndpoint() bool { if !e.supportsAlias() { if _, ok := e.GetBoolProviderSpecificProperty(providerSpecificAlias); ok { log.Warnf("Endpoint %s of type %s does not support alias records", e.DNSName, e.RecordType) return false } } switch recordType := e.RecordType; recordType { case RecordTypeA, RecordTypeAAAA: if !e.isAlias() { return e.Targets.ValidateIPRecord(recordType) } case RecordTypeMX: return e.Targets.ValidateMXRecord() case RecordTypeSRV: return e.Targets.ValidateSRVRecord() case RecordTypePTR: return e.ValidatePTRRecord() } return true } // isAlias returns true if the endpoint has the alias provider-specific property set to true. func (e *Endpoint) isAlias() bool { val, ok := e.GetBoolProviderSpecificProperty(providerSpecificAlias) return ok && val } func (e *Endpoint) supportsAlias() bool { switch e.RecordType { case RecordTypeA, RecordTypeAAAA, RecordTypeCNAME: return true default: return false } } // WithMinTTL sets the endpoint's TTL to the given value if the current TTL is not configured. func (e *Endpoint) WithMinTTL(ttl int64) { if !e.RecordTTL.IsConfigured() && ttl > 0 { log.Debugf("Overriding existing TTL %d with new value %d for endpoint %s", e.RecordTTL, ttl, e.DNSName) e.RecordTTL = TTL(ttl) } } // NewMXRecord parses a string representation of an MX record target (e.g., "10 mail.example.com") // and returns an MXTarget struct. Returns an error if the input is invalid. func NewMXRecord(target string) (*MXTarget, error) { parts := strings.Fields(strings.TrimSpace(target)) if len(parts) != 2 { return nil, fmt.Errorf("invalid MX record target: %s. MX records must have a preference value and a host, e.g. '10 example.com'", target) } priority, err := strconv.ParseUint(parts[0], 10, 16) if err != nil { return nil, fmt.Errorf("invalid integer value in target: %s", target) } return &MXTarget{ priority: uint16(priority), host: parts[1], }, nil } // GetPriority returns the priority of the MX record target. func (m *MXTarget) GetPriority() *uint16 { return &m.priority } // GetHost returns the host of the MX record target. func (m *MXTarget) GetHost() *string { return &m.host } func (t Targets) ValidateIPRecord(recordType string) bool { for _, target := range t { addr, err := netip.ParseAddr(target) if err != nil { log.Debugf("Invalid %s record target: %s is not a valid IP address", recordType, target) return false } if recordType == RecordTypeA && addr.Is6() { log.Debugf("Invalid A record target: %s is an IPv6 address", target) return false } if recordType == RecordTypeAAAA && addr.Is4() { log.Debugf("Invalid AAAA record target: %s is an IPv4 address", target) return false } } return true } func (t Targets) ValidateMXRecord() bool { for _, target := range t { _, err := NewMXRecord(target) if err != nil { log.Debugf("Invalid MX record target: %s. %v", target, err) return false } } return true } func (t Targets) ValidateSRVRecord() bool { for _, target := range t { // SRV records must have a priority, weight, a port value and a target e.g. "10 5 5060 example.com." // as per https://www.rfc-editor.org/rfc/rfc2782.txt the target host has to end with a dot. targetParts := strings.Fields(strings.TrimSpace(target)) if len(targetParts) != 4 { log.Debugf("Invalid SRV record target: %s. SRV records must have a priority, weight, a port value and a target host, e.g. '10 5 5060 example.com.'", target) return false } if !strings.HasSuffix(targetParts[3], ".") { log.Debugf("Invalid SRV record target: %s. Target host does not end with a dot.'", target) return false } for _, part := range targetParts[:3] { _, err := strconv.ParseUint(part, 10, 16) if err != nil { log.Debugf("Invalid SRV record target: %s. Invalid integer value in target.", target) return false } } } return true } // ValidatePTRRecord checks that a PTR endpoint has a valid reverse DNS name // (ending in .in-addr.arpa or .ip6.arpa) and that targets are non-empty hostnames. func (e *Endpoint) ValidatePTRRecord() bool { name := strings.ToLower(e.DNSName) if !isReverseDNSName(name) { log.Debugf("Invalid PTR record: DNSName %q must be a valid reverse DNS name under .in-addr.arpa or .ip6.arpa", e.DNSName) return false } if len(e.Targets) == 0 { log.Debugf("Invalid PTR record: at least one target is required for %s", e.DNSName) return false } for _, target := range e.Targets { if strings.TrimSpace(target) == "" { log.Debugf("Invalid PTR record: target must not be empty for %s", e.DNSName) return false } if _, err := netip.ParseAddr(target); err == nil { log.Debugf("Invalid PTR record: target %q for %s must be a hostname, not an IP address", target, e.DNSName) return false } } return true } // isReverseDNSName checks that name ends with .in-addr.arpa or .ip6.arpa // and has at least one label before the suffix. func isReverseDNSName(name string) bool { for _, suffix := range []string{".in-addr.arpa", ".ip6.arpa"} { if prefix, ok := strings.CutSuffix(name, suffix); ok { return len(prefix) > 0 && prefix[0] != '.' } } return false } // GetDNSName returns the DNS name of the endpoint. func (e *Endpoint) GetDNSName() string { return e.DNSName } // GetRecordType returns the record type of the endpoint. func (e *Endpoint) GetRecordType() string { return e.RecordType } // GetRecordTTL returns the TTL of the endpoint as int64. func (e *Endpoint) GetRecordTTL() int64 { return int64(e.RecordTTL) } // GetTargets returns the targets of the endpoint. func (e *Endpoint) GetTargets() []string { return e.Targets } // GetOwner returns the owner of the endpoint from labels or set identifier. func (e *Endpoint) GetOwner() string { if val, ok := e.Labels[OwnerLabelKey]; ok { return val } return e.SetIdentifier } ================================================ FILE: endpoint/endpoint_benchmark_test.go ================================================ /* Copyright 2026 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 endpoint import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) var ( providerSpecificKeys = []string{ "alias", "provider/target-hosted-zone", "provider/evaluate-target-health", "provider/weight", "provider/region", "provider/failover", "provider/geolocation-continent-code", "provider/geolocation-country-code", "provider/geolocation-subdivision-code", "provider/geoproximity-region", "provider/geoproximity-bias", "provider/geoproximity-coordinates", "provider/geoproximity-local-zone-group", "provider/multi-value-answer", "provider/health-check-id", "same-zone", } ) // TestEndpointGeneration validates that generateBenchmarkEndpoints // creates correct data for both slice implementations. func TestEndpointGeneration(t *testing.T) { for _, setProps := range []int{0, 1, 3, 5, 16} { t.Run(fmt.Sprintf("set=%d", setProps), func(t *testing.T) { endpoints := generateBenchmarkEndpoints(10, setProps) assert.Len(t, endpoints, 10) for _, ep := range endpoints { assert.Len(t, ep.ProviderSpecific, setProps) } }) } } // BenchmarkProviderSpecificRealisticAccess simulates realistic provider behavior: // The provider checks ALL its supported properties on each endpoint, // even though only a few (setProps) are actually configured. func BenchmarkProviderSpecificRandomAccess(b *testing.B) { // setProps: how many properties are actually set on the endpoint // The provider will still check all 16 keys setPropsOptions := []int{0, 1, 5, 9, 16} endpointCounts := []int{100, 1000, 10000, 50000, 100000, 200000} keys := []string{ "provider/weight", "nonexistent", "provider/geoproximity-region", "same-zone", } for _, setProps := range setPropsOptions { for _, epCount := range endpointCounts { endpoints := generateBenchmarkEndpoints(epCount, setProps) b.Run(fmt.Sprintf("slice/set=%d/endpoints=%d", setProps, epCount), func(b *testing.B) { for b.Loop() { for _, ep := range endpoints { // Provider checks random supported properties for _, key := range keys { ep.GetProviderSpecificProperty(key) } } } }) } } } func BenchmarkProviderSpecificDelete(b *testing.B) { propertyCounts := []int{0, 5, 10} endpointCounts := []int{100, 300, 1000, 10000, 50000} keys := []string{ "provider/weight", "nonexistent", } for _, propCount := range propertyCounts { for _, epCount := range endpointCounts { b.Run(fmt.Sprintf("slice/props=%d/endpoints=%d", propCount, epCount), func(b *testing.B) { template := generateBenchmarkEndpoints(epCount, propCount) b.ResetTimer() for b.Loop() { // Shallow copy is enough if we only care about the slice structure endpoints := make([]*Endpoint, len(template)) copy(endpoints, template) for _, ep := range endpoints { for _, key := range keys { ep.DeleteProviderSpecificProperty(key) } } } }) } } } // generateBenchmarkEndpoints creates endpoints with realistic AWS provider-specific properties. // setPropsCount determines how many of the providerSpecificKeys are actually set on each endpoint. func generateBenchmarkEndpoints(count, setPropsCount int) []*Endpoint { endpoints := make([]*Endpoint, count) for i := range count { ep := &Endpoint{ DNSName: fmt.Sprintf("endpoint-%d.example.com", i), RecordType: RecordTypeA, Targets: Targets{fmt.Sprintf("192.0.2.%d", i%256)}, RecordTTL: TTL(300), Labels: NewLabels(), } // Set only the first setPropsCount properties if setPropsCount > 0 { ep.ProviderSpecific = make(ProviderSpecific, setPropsCount) for j := range setPropsCount { key := providerSpecificKeys[j%len(providerSpecificKeys)] ep.ProviderSpecific[j] = ProviderSpecificProperty{ Name: key, Value: fmt.Sprintf("value-%d", j), } } } endpoints[i] = ep } return endpoints } ================================================ FILE: endpoint/endpoint_test.go ================================================ /* Copyright 2017 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 endpoint import ( "fmt" "reflect" "slices" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/pkg/events" ) func TestNewEndpoint(t *testing.T) { e := NewEndpoint("example.org", "CNAME", "foo.com") if e.DNSName != "example.org" || e.Targets[0] != "foo.com" || e.RecordType != "CNAME" { t.Error("endpoint is not initialized correctly") } if e.Labels == nil { t.Error("Labels is not initialized") } w := NewEndpoint("example.org.", "", "load-balancer.com.") if w.DNSName != "example.org" || w.Targets[0] != "load-balancer.com" || w.RecordType != "" { t.Error("endpoint is not initialized correctly") } } func TestNewTargets(t *testing.T) { cases := []struct { name string input []string expected Targets }{ { name: "no targets", input: []string{}, expected: Targets{}, }, { name: "single target", input: []string{"1.2.3.4"}, expected: Targets{"1.2.3.4"}, }, { name: "multiple targets", input: []string{"example.com", "8.8.8.8", "::0001"}, expected: Targets{"8.8.8.8", "::0001", "example.com"}, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { Targets := NewTargets(c.input...) changedTarget := Targets.String() assert.Equal(t, c.expected.String(), changedTarget) }) } } func TestTargetsSame(t *testing.T) { tests := []Targets{ {""}, {"1.2.3.4"}, {"8.8.8.8", "8.8.4.4"}, {"dd:dd::01", "::1", "::0001"}, {"example.org", "EXAMPLE.ORG"}, } for _, d := range tests { if d.Same(d) != true { t.Errorf("%#v should equal %#v", d, d) } } } func TestSameSuccess(t *testing.T) { tests := []struct { a Targets b Targets }{ { []string{"::1"}, []string{"::0001"}, }, { []string{"::1", "dd:dd::01"}, []string{"dd:00dd::0001", "::0001"}, }, { []string{"::1", "dd:dd::01"}, []string{"00dd:dd::0001", "::0001"}, }, { []string{"::1", "1.1.1.1", "2600.com", "3.3.3.3"}, []string{"2600.com", "::0001", "3.3.3.3", "1.1.1.1"}, }, } for _, d := range tests { if d.a.Same(d.b) == false { t.Errorf("%#v should equal %#v", d.a, d.b) } } } func TestSameFailures(t *testing.T) { tests := []struct { a Targets b Targets }{ { []string{"1.2.3.4"}, []string{"4.3.2.1"}, }, { []string{"1.2.3.4"}, []string{"1.2.3.4", "4.3.2.1"}, }, { []string{"1.2.3.4", "4.3.2.1"}, []string{"1.2.3.4"}, }, { []string{"1.2.3.4", "4.3.2.1"}, []string{"8.8.8.8", "8.8.4.4"}, }, { []string{"::1", "2600.com", "3.3.3.3"}, []string{"2600.com", "3.3.3.3", "1.1.1.1"}, }, } for _, d := range tests { if d.a.Same(d.b) == true { t.Errorf("%#v should not equal %#v", d.a, d.b) } } } func TestIsLess(t *testing.T) { testsA := []Targets{ {""}, {"1.2.3.4"}, {"1.2.3.4"}, {"example.org", "example.com"}, {"8.8.8.8", "8.8.4.4"}, {"1-2-3-4.example.org", "EXAMPLE.ORG"}, {"1-2-3-4.example.org", "EXAMPLE.ORG", "1.2.3.4"}, {"example.com", "example.org"}, } testsB := []Targets{ {"", ""}, {"1-2-3-4.example.org"}, {"1.2.3.5"}, {"example.com", "examplea.org"}, {"8.8.8.8"}, {"1.2.3.4", "EXAMPLE.ORG"}, {"1-2-3-4.example.org", "EXAMPLE.ORG"}, {"example.com", "example.org"}, } expected := []bool{ true, true, true, true, false, false, false, false, } for i, d := range testsA { if d.IsLess(testsB[i]) != expected[i] { t.Errorf("%v < %v is expected to be %v", d, testsB[i], expected[i]) } } } func TestGetProviderSpecificProperty(t *testing.T) { t.Run("empty provider specific", func(t *testing.T) { e := &Endpoint{} val, ok := e.GetProviderSpecificProperty("any") assert.False(t, ok) assert.Empty(t, val) }) t.Run("key is not present in provider specific", func(t *testing.T) { e := &Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "name", Value: "value"}, }, } val, ok := e.GetProviderSpecificProperty("hello") assert.False(t, ok) assert.Empty(t, val) }) t.Run("key is present in provider specific", func(t *testing.T) { e := &Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "name", Value: "value"}, }, } val, ok := e.GetProviderSpecificProperty("name") assert.True(t, ok) assert.Equal(t, "value", val) }) } func TestSetProviderSpecficProperty(t *testing.T) { cases := []struct { name string endpoint Endpoint key string value string expectedIdentifier string expected []ProviderSpecificProperty }{ { name: "endpoint is empty", endpoint: Endpoint{}, key: "key1", value: "value1", expected: []ProviderSpecificProperty{ { Name: "key1", Value: "value1", }, }, }, { name: "name and key are not matching", endpoint: Endpoint{ DNSName: "example.org", RecordTTL: TTL(0), RecordType: RecordTypeA, SetIdentifier: "newIdentifier", Targets: Targets{ "example.org", "example.com", "1.2.4.5", }, ProviderSpecific: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, }, }, expectedIdentifier: "newIdentifier", key: "name2", value: "value2", expected: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, { Name: "name2", Value: "value2", }, }, }, { name: "some keys are matching and some are not matching ", endpoint: Endpoint{ DNSName: "example.org", RecordTTL: TTL(0), RecordType: RecordTypeA, SetIdentifier: "newIdentifier", Targets: Targets{ "example.org", "example.com", "1.2.4.5", }, ProviderSpecific: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, { Name: "name2", Value: "value2", }, { Name: "name3", Value: "value3", }, }, }, key: "name2", value: "value2", expectedIdentifier: "newIdentifier", expected: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, { Name: "name2", Value: "value2", }, { Name: "name3", Value: "value3", }, }, }, { name: "name and key are not matching", endpoint: Endpoint{ DNSName: "example.org", RecordTTL: TTL(0), RecordType: RecordTypeA, SetIdentifier: "identifier", Targets: Targets{ "example.org", "example.com", "1.2.4.5", }, ProviderSpecific: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, }, }, key: "name1", value: "value2", expectedIdentifier: "identifier", expected: []ProviderSpecificProperty{ { Name: "name1", Value: "value2", }, }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { c.endpoint.WithProviderSpecific(c.key, c.value) expectedString := fmt.Sprintf("%s %d IN %s %s %s %s", c.endpoint.DNSName, c.endpoint.RecordTTL, c.endpoint.RecordType, c.endpoint.SetIdentifier, c.endpoint.Targets, c.endpoint.ProviderSpecific) identifier := c.endpoint.WithSetIdentifier(c.endpoint.SetIdentifier) assert.Equal(t, c.expectedIdentifier, identifier.SetIdentifier) assert.Equal(t, expectedString, c.endpoint.String()) if !reflect.DeepEqual([]ProviderSpecificProperty(c.endpoint.ProviderSpecific), c.expected) { t.Errorf("unexpected ProviderSpecific:\nGot: %#v\nExpected: %#v", c.endpoint.ProviderSpecific, c.expected) } }) } } func TestDeleteProviderSpecificProperty(t *testing.T) { cases := []struct { name string endpoint Endpoint key string expected []ProviderSpecificProperty }{ { name: "empty provider specific", endpoint: Endpoint{}, key: "any", expected: nil, }, { name: "name and key are not matching", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, }, }, key: "name2", expected: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, }, }, { name: "some keys are matching and some keys are not matching", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, { Name: "name2", Value: "value2", }, { Name: "name3", Value: "value3", }, }, }, key: "name2", expected: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, { Name: "name3", Value: "value3", }, }, }, { name: "name and key are matching", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ { Name: "name1", Value: "value1", }, }, }, key: "name1", expected: []ProviderSpecificProperty{}, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { c.endpoint.DeleteProviderSpecificProperty(c.key) if !reflect.DeepEqual([]ProviderSpecificProperty(c.endpoint.ProviderSpecific), c.expected) { t.Errorf("unexpected ProviderSpecific:\nGot: %#v\nExpected: %#v", c.endpoint.ProviderSpecific, c.expected) } }) } } func TestRetainProviderProperties(t *testing.T) { cases := []struct { name string endpoint Endpoint provider string expected []ProviderSpecificProperty }{ { name: "empty provider specific", endpoint: Endpoint{}, provider: "aws", expected: nil, }, { name: "empty provider, properties untouched", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "coredns/group", Value: "my-group"}, }, }, provider: "", expected: []ProviderSpecificProperty{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "coredns/group", Value: "my-group"}, }, }, { name: "all properties match provider", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "aws/weight", Value: "10"}, }, }, provider: "aws", expected: []ProviderSpecificProperty{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "aws/weight", Value: "10"}, }, }, { name: "no properties match provider", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "coredns/group", Value: "my-group"}, }, }, provider: "aws", expected: []ProviderSpecificProperty{}, }, { name: "mixed providers, only configured provider retained", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "coredns/group", Value: "my-group"}, {Name: "aws/weight", Value: "10"}, }, }, provider: "aws", expected: []ProviderSpecificProperty{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "aws/weight", Value: "10"}, }, }, { name: "provider agnostic properties without prefix are retained", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "alias", Value: "true"}, {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "coredns/group", Value: "my-group"}, }, }, provider: "aws", expected: []ProviderSpecificProperty{ {Name: "alias", Value: "true"}, {Name: "aws/evaluate-target-health", Value: "true"}, }, }, { name: "provider prefix must match exactly, not as substring", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "aws-extended/some-prop", Value: "val"}, {Name: "aws/weight", Value: "10"}, }, }, provider: "aws", expected: []ProviderSpecificProperty{ {Name: "aws/weight", Value: "10"}, }, }, // cloudflare uses annotation-style names (e.g. "external-dns.alpha.kubernetes.io/cloudflare-*") // rather than the standard "provider/" prefix, so all properties are retained and only sorted. { name: "cloudflare retains all properties", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"}, {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "alias", Value: "false"}, }, }, provider: "cloudflare", expected: []ProviderSpecificProperty{ {Name: "alias", Value: "false"}, {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"}, }, }, { name: "cloudflare properties are sorted", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true"}, {Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"}, }, }, provider: "cloudflare", expected: []ProviderSpecificProperty{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true"}, {Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"}, }, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { c.endpoint.RetainProviderProperties(c.provider) require.Equal(t, c.expected, []ProviderSpecificProperty(c.endpoint.ProviderSpecific)) }) } } func TestFilterEndpointsByOwnerIDWithRecordTypeA(t *testing.T) { foo1 := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "foo", }, } foo2 := &Endpoint{ DNSName: "foo2.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "foo", }, } bar := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "bar", }, } type args struct { ownerID string eps []*Endpoint } tests := []struct { name string args args want []*Endpoint }{ { name: "filter values", args: args{ ownerID: "foo", eps: []*Endpoint{ foo1, foo2, bar, }, }, want: []*Endpoint{ foo1, foo2, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := FilterEndpointsByOwnerID(tt.args.ownerID, tt.args.eps); !reflect.DeepEqual(got, tt.want) { t.Errorf("ApplyEndpointFilter() = %v, want %v", got, tt.want) } }) } } func TestFilterEndpointsByOwnerIDWithRecordTypeCNAME(t *testing.T) { foo1 := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeCNAME, Labels: Labels{ OwnerLabelKey: "foo", }, } foo2 := &Endpoint{ DNSName: "foo2.com", RecordType: RecordTypeCNAME, Labels: Labels{ OwnerLabelKey: "foo", }, } bar := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeCNAME, Labels: Labels{ OwnerLabelKey: "bar", }, } type args struct { ownerID string eps []*Endpoint } tests := []struct { name string args args want []*Endpoint }{ { name: "filter values", args: args{ ownerID: "foo", eps: []*Endpoint{ foo1, foo2, bar, }, }, want: []*Endpoint{ foo1, foo2, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := FilterEndpointsByOwnerID(tt.args.ownerID, tt.args.eps); !reflect.DeepEqual(got, tt.want) { t.Errorf("ApplyEndpointFilter() = %v, want %v", got, tt.want) } }) } } func TestFilterEndpointsByOwnerID_Logs(t *testing.T) { const ( msgMismatch = "owner id does not match" msgMissing = "missing owner label" ) matching := &Endpoint{DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{OwnerLabelKey: "foo"}} mismatch := &Endpoint{DNSName: "bar.com", RecordType: RecordTypeA, Labels: Labels{OwnerLabelKey: "bar"}} noLabel := &Endpoint{DNSName: "baz.com", RecordType: RecordTypeA} tests := []struct { name string eps []*Endpoint wantLogs []string }{ { name: "no log: all endpoints match owner", eps: []*Endpoint{matching}, }, { name: "logs owner mismatch", eps: []*Endpoint{matching, mismatch}, wantLogs: []string{msgMismatch}, }, { name: "logs missing owner label", eps: []*Endpoint{matching, noLabel}, wantLogs: []string{msgMissing}, }, { name: "logs both mismatch and missing label", eps: []*Endpoint{matching, mismatch, noLabel}, wantLogs: []string{msgMismatch, msgMissing}, }, } allMsgs := []string{msgMismatch, msgMissing} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) FilterEndpointsByOwnerID("foo", tt.eps) for _, msg := range allMsgs { if slices.Contains(tt.wantLogs, msg) { logtest.TestHelperLogContainsWithLogLevel(msg, log.DebugLevel, hook, t) } else { logtest.TestHelperLogNotContains(msg, hook, t) } } }) } } func TestIsOwnedBy(t *testing.T) { type fields struct { Labels Labels } type args struct { ownerID string } tests := []struct { name string fields fields args args want bool }{ { name: "empty labels", fields: fields{Labels: Labels{}}, args: args{ownerID: "foo"}, want: false, }, { name: "owner label not match", fields: fields{Labels: Labels{OwnerLabelKey: "bar"}}, args: args{ownerID: "foo"}, want: false, }, { name: "owner label match", fields: fields{Labels: Labels{OwnerLabelKey: "foo"}}, args: args{ownerID: "foo"}, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { e := &Endpoint{ Labels: tt.fields.Labels, } if got := e.IsOwnedBy(tt.args.ownerID); got != tt.want { t.Errorf("Endpoint.isOwnedBy() = %v, want %v", got, tt.want) } }) } } func TestDuplicatedEndpointsWithSimpleZone(t *testing.T) { foo1 := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "foo", }, } foo2 := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "foo", }, } bar := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "bar", }, } type args struct { eps []*Endpoint } tests := []struct { name string args args want []*Endpoint }{ { name: "filter values", args: args{ eps: []*Endpoint{ foo1, foo2, bar, }, }, want: []*Endpoint{ foo1, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := RemoveDuplicates(tt.args.eps); !reflect.DeepEqual(got, tt.want) { t.Errorf("RemoveDuplicates() = %v, want %v", got, tt.want) } }) } } func TestDuplicatedEndpointsWithOverlappingZones(t *testing.T) { foo1 := &Endpoint{ DNSName: "internal.foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "foo", }, } foo2 := &Endpoint{ DNSName: "internal.foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "foo", }, } foo3 := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "foo", }, } foo4 := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "foo", }, } bar := &Endpoint{ DNSName: "internal.foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "bar", }, } bar2 := &Endpoint{ DNSName: "foo.com", RecordType: RecordTypeA, Labels: Labels{ OwnerLabelKey: "bar", }, } type args struct { eps []*Endpoint } tests := []struct { name string args args want []*Endpoint }{ { name: "filter values", args: args{ eps: []*Endpoint{ foo1, foo2, foo3, foo4, bar, bar2, }, }, want: []*Endpoint{ foo1, foo3, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := RemoveDuplicates(tt.args.eps); !reflect.DeepEqual(got, tt.want) { t.Errorf("RemoveDuplicates() = %v, want %v", got, tt.want) } }) } } func TestPDNScheckEndpoint(t *testing.T) { tests := []struct { description string endpoint Endpoint expected bool }{ { description: "Valid MX record target", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 example.com"}, }, expected: true, }, { description: "Valid MX record with multiple targets", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 example.com", "20 backup.example.com"}, }, expected: true, }, { description: "MX record with valid and invalid targets", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"example.com", "backup.example.com"}, }, expected: false, }, { description: "Invalid MX record with missing priority value", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"example.com"}, }, expected: false, }, { description: "Invalid MX record with too many arguments", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 example.com abc"}, }, expected: false, }, { description: "Invalid MX record with non-integer priority", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"abc example.com"}, }, expected: false, }, { description: "Valid SRV record target", endpoint: Endpoint{ DNSName: "_service._tls.example.com", RecordType: RecordTypeSRV, Targets: Targets{"10 20 5060 service.example.com."}, }, expected: true, }, { description: "Invalid SRV record with missing part", endpoint: Endpoint{ DNSName: "_service._tls.example.com", RecordType: RecordTypeSRV, Targets: Targets{"10 20 5060"}, }, expected: false, }, { description: "Invalid SRV record with non-integer part", endpoint: Endpoint{ DNSName: "_service._tls.example.com", RecordType: RecordTypeSRV, Targets: Targets{"10 20 abc service.example.com."}, }, expected: false, }, { description: "Invalid SRV record with missing dot for target host", endpoint: Endpoint{ DNSName: "_service._tls.example.com", RecordType: RecordTypeSRV, Targets: Targets{"10 20 5060 service.example.com"}, }, expected: false, }, } for _, tt := range tests { actual := tt.endpoint.CheckEndpoint() assert.Equal(t, tt.expected, actual) } } func TestNewMXTarget(t *testing.T) { tests := []struct { description string target string expected *MXTarget expectError bool }{ { description: "Valid MX record", target: "10 example.com", expected: &MXTarget{priority: 10, host: "example.com"}, expectError: false, }, { description: "Invalid MX record with missing priority", target: "example.com", expectError: true, }, { description: "Invalid MX record with non-integer priority", target: "abc example.com", expectError: true, }, { description: "Invalid MX record with too many parts", target: "10 example.com extra", expectError: true, }, { description: "Missing host", target: "10 ", expected: nil, expectError: true, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { actual, err := NewMXRecord(tt.target) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.expected, actual) } }) } } func TestCheckEndpoint(t *testing.T) { tests := []struct { description string endpoint Endpoint expected bool }{ { description: "Valid MX record target", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 example.com"}, }, expected: true, }, { description: "Invalid MX record target", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"example.com"}, }, expected: false, }, { description: "Valid SRV record target", endpoint: Endpoint{ DNSName: "_service._tcp.example.com", RecordType: RecordTypeSRV, Targets: Targets{"10 5 5060 example.com."}, }, expected: true, }, { description: "Invalid SRV record target", endpoint: Endpoint{ DNSName: "_service._tcp.example.com", RecordType: RecordTypeSRV, Targets: Targets{"10 5 example.com."}, }, expected: false, }, { description: "Non-MX/SRV record type", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeA, Targets: Targets{"192.168.1.1"}, }, expected: true, }, { description: "Valid AAAA record", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeAAAA, Targets: Targets{"2001:db8::1"}, }, expected: true, }, { description: "Invalid A record - not an IP", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeA, Targets: Targets{"not-an-ip"}, }, expected: false, }, { description: "Invalid A record - IPv6 address", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeA, Targets: Targets{"2001:db8::1"}, }, expected: false, }, { description: "Invalid AAAA record - IPv4 address", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeAAAA, Targets: Targets{"192.168.1.1"}, }, expected: false, }, { description: "Invalid AAAA record - not an IP", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeAAAA, Targets: Targets{"not-an-ip"}, }, expected: false, }, { description: "A record with alias=true is valid", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeA, Targets: Targets{"my-elb-123.us-east-1.elb.amazonaws.com"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, expected: true, }, { description: "AAAA record with alias=true is valid", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeAAAA, Targets: Targets{"dualstack.my-elb-123.us-east-1.elb.amazonaws.com"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, expected: true, }, { description: "CNAME record with alias=true is valid", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeCNAME, Targets: Targets{"d111111abcdef8.cloudfront.net"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, expected: true, }, { description: "MX record with alias=true is invalid", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 mail.example.com"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, expected: false, }, { description: "TXT record with alias=true is invalid", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeTXT, Targets: Targets{"v=spf1 ~all"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, expected: false, }, { description: "NS record with alias=true is invalid", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeNS, Targets: Targets{"ns1.example.com"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, expected: false, }, { description: "SRV record with alias=true is invalid", endpoint: Endpoint{ DNSName: "_sip._tcp.example.com", RecordType: RecordTypeSRV, Targets: Targets{"10 5 5060 sip.example.com."}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, expected: false, }, { description: "MX record with alias=false is also invalid", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 mail.example.com"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "false"}}, }, expected: false, }, { description: "MX record without alias property is valid", endpoint: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 mail.example.com"}, }, expected: true, }, { description: "Valid PTR record with in-addr.arpa", endpoint: Endpoint{ DNSName: "2.49.168.192.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{"web.example.com"}, }, expected: true, }, { description: "Valid PTR record with ip6.arpa", endpoint: Endpoint{ DNSName: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", RecordType: RecordTypePTR, Targets: Targets{"v6.example.com"}, }, expected: true, }, { description: "Valid PTR record with multiple hostname targets", endpoint: Endpoint{ DNSName: "1.0.0.10.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{"a.example.com", "b.example.com"}, }, expected: true, }, { description: "Invalid PTR record - DNS name not reverse DNS", endpoint: Endpoint{ DNSName: "web.example.com", RecordType: RecordTypePTR, Targets: Targets{"10.0.0.1"}, }, expected: false, }, { description: "Invalid PTR record - target is an IP address", endpoint: Endpoint{ DNSName: "1.0.0.10.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{"10.0.0.1"}, }, expected: false, }, { description: "Invalid PTR record - target is an IPv6 address", endpoint: Endpoint{ DNSName: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", RecordType: RecordTypePTR, Targets: Targets{"2001:db8::1"}, }, expected: false, }, { description: "Invalid PTR record - empty target", endpoint: Endpoint{ DNSName: "1.0.0.10.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{""}, }, expected: false, }, { description: "Invalid PTR record - no targets", endpoint: Endpoint{ DNSName: "1.0.0.10.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{}, }, expected: false, }, { description: "Invalid PTR record - bare in-addr.arpa", endpoint: Endpoint{ DNSName: "in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{"web.example.com"}, }, expected: false, }, { description: "Invalid PTR record - dot-prefixed in-addr.arpa", endpoint: Endpoint{ DNSName: ".in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{"web.example.com"}, }, expected: false, }, { description: "Invalid PTR record - bare ip6.arpa", endpoint: Endpoint{ DNSName: "ip6.arpa", RecordType: RecordTypePTR, Targets: Targets{"web.example.com"}, }, expected: false, }, { description: "Invalid PTR record - dot-prefixed ip6.arpa", endpoint: Endpoint{ DNSName: ".ip6.arpa", RecordType: RecordTypePTR, Targets: Targets{"web.example.com"}, }, expected: false, }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { actual := tt.endpoint.CheckEndpoint() assert.Equal(t, tt.expected, actual) }) } } func TestCheckEndpoint_AliasWarningLog(t *testing.T) { tests := []struct { name string ep Endpoint wantLog bool }{ { name: "unsupported type with alias logs warning", ep: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 mail.example.com"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, wantLog: true, }, { name: "supported type with alias does not log", ep: Endpoint{ DNSName: "example.com", RecordType: RecordTypeA, Targets: Targets{"my-elb-123.us-east-1.elb.amazonaws.com"}, ProviderSpecific: ProviderSpecific{{Name: providerSpecificAlias, Value: "true"}}, }, wantLog: false, }, { name: "unsupported type without alias does not log", ep: Endpoint{ DNSName: "example.com", RecordType: RecordTypeMX, Targets: Targets{"10 mail.example.com"}, }, wantLog: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) tt.ep.CheckEndpoint() warnMsg := "does not support alias records" if tt.wantLog { logtest.TestHelperLogContains(warnMsg, hook, t) } else { logtest.TestHelperLogNotContains(warnMsg, hook, t) } }) } } func TestCheckEndpoint_PTRValidationLog(t *testing.T) { tests := []struct { name string ep Endpoint wantLog string }{ { name: "non-reverse DNS name logs invalid", ep: Endpoint{ DNSName: "web.example.com", RecordType: RecordTypePTR, Targets: Targets{"other.example.com"}, }, wantLog: "must be a valid reverse DNS name", }, { name: "IP address target logs invalid", ep: Endpoint{ DNSName: "1.0.0.10.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{"10.0.0.1"}, }, wantLog: "must be a hostname, not an IP address", }, { name: "empty target logs invalid", ep: Endpoint{ DNSName: "1.0.0.10.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{""}, }, wantLog: "target must not be empty", }, { name: "no targets logs invalid", ep: Endpoint{ DNSName: "1.0.0.10.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{}, }, wantLog: "at least one target is required", }, { name: "valid PTR does not log", ep: Endpoint{ DNSName: "2.49.168.192.in-addr.arpa", RecordType: RecordTypePTR, Targets: Targets{"web.example.com"}, }, wantLog: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) tt.ep.CheckEndpoint() if tt.wantLog != "" { logtest.TestHelperLogContains(tt.wantLog, hook, t) } else { logtest.TestHelperLogNotContains("Invalid PTR record", hook, t) } }) } } func TestEndpoint_WithRefObject(t *testing.T) { ep := &Endpoint{} ref := &events.ObjectReference{ Kind: "Service", Namespace: "default", Name: "my-service", } result := ep.WithRefObject(ref) assert.Equal(t, ref, ep.RefObject(), "refObject should be set") assert.Equal(t, ep, result, "should return the same Endpoint pointer") } func TestTargets_UniqueOrdered(t *testing.T) { tests := []struct { name string input Targets expected Targets }{ { name: "no duplicates", input: Targets{"a.example.com", "b.example.com"}, expected: Targets{"a.example.com", "b.example.com"}, }, { name: "with duplicates", input: Targets{"a.example.com", "b.example.com", "a.example.com"}, expected: Targets{"a.example.com", "b.example.com"}, }, { name: "all duplicates", input: []string{"a.example.com", "a.example.com", "a.example.com"}, expected: Targets{"a.example.com"}, }, { name: "already sorted", input: Targets{"a.example.com", "c.example.com", "d.example.com"}, expected: Targets{"a.example.com", "c.example.com", "d.example.com"}, }, { name: "unsorted input", input: Targets{"z.example.com", "a.example.com", "m.example.com"}, expected: Targets{"a.example.com", "m.example.com", "z.example.com"}, }, { name: "empty input", input: Targets{}, expected: Targets{}, }, { name: "single element", input: Targets{"only.example.com"}, expected: Targets{"only.example.com"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := NewTargets(tt.input...) assert.Equal(t, tt.expected, result) }) } } func TestEndpoint_WithMinTTL(t *testing.T) { tests := []struct { name string initialTTL TTL inputTTL int64 expectedTTL TTL isConfigured bool }{ { name: "sets TTL when not configured and input > 0", initialTTL: 0, inputTTL: 300, expectedTTL: 300, isConfigured: true, }, { name: "does not override when already configured", initialTTL: 120, inputTTL: 300, expectedTTL: 120, isConfigured: true, }, { name: "does not set when input is zero", initialTTL: 30, inputTTL: 0, expectedTTL: 30, isConfigured: true, }, { name: "does not set when input is negative", initialTTL: 0, inputTTL: -10, expectedTTL: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ep := &Endpoint{RecordTTL: tt.initialTTL} ep.WithMinTTL(tt.inputTTL) assert.Equal(t, tt.expectedTTL, ep.RecordTTL) assert.Equal(t, tt.isConfigured, ep.RecordTTL.IsConfigured()) }) } } // TestNewEndpointWithTTLPreservesDotsInTXTRecords tests that trailing dots are preserved in TXT records func TestNewEndpointWithTTLPreservesDotsInTXTRecords(t *testing.T) { // TXT records should preserve trailing dots (and any arbitrary text) txtEndpoint := NewEndpointWithTTL("example.com", RecordTypeTXT, TTL(300), "v=1;some_signature=aBx3d5..", "text.with.dots...", "simple-text") require.NotNil(t, txtEndpoint, "TXT endpoint should be created") require.Len(t, txtEndpoint.Targets, 3, "should have 3 targets") // All dots should be preserved in TXT targets assert.Equal(t, "v=1;some_signature=aBx3d5..", txtEndpoint.Targets[0]) assert.Equal(t, "text.with.dots...", txtEndpoint.Targets[1]) assert.Equal(t, "simple-text", txtEndpoint.Targets[2]) // Domain name record types should still have trailing dots trimmed aEndpoint := NewEndpointWithTTL("example.com", RecordTypeA, TTL(300), "1.2.3.4.") require.NotNil(t, aEndpoint, "A endpoint should be created") assert.Equal(t, "1.2.3.4", aEndpoint.Targets[0], "A record should have trailing dot trimmed") cnameEndpoint := NewEndpointWithTTL("example.com", RecordTypeCNAME, TTL(300), "target.example.com.") require.NotNil(t, cnameEndpoint, "CNAME endpoint should be created") assert.Equal(t, "target.example.com", cnameEndpoint.Targets[0], "CNAME record should have trailing dot trimmed") } func TestGetBoolProviderSpecificProperty(t *testing.T) { tests := []struct { name string endpoint Endpoint key string expectedValue bool expectedExists bool }{ { name: "key does not exist", endpoint: Endpoint{}, key: "nonexistent", expectedValue: false, expectedExists: false, }, { name: "key exists with true value", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "enabled", Value: "true"}, }, }, key: "enabled", expectedValue: true, expectedExists: true, }, { name: "key exists with false value", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "disabled", Value: "false"}, }, }, key: "disabled", expectedValue: false, expectedExists: true, }, { name: "key exists with invalid boolean value", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "invalid", Value: "maybe"}, }, }, key: "invalid", expectedValue: false, expectedExists: true, }, { name: "key exists with empty value", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "empty", Value: ""}, }, }, key: "empty", expectedValue: false, expectedExists: true, }, { name: "key exists with numeric value", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "numeric", Value: "1"}, }, }, key: "numeric", expectedValue: false, expectedExists: true, }, { name: "multiple properties, find correct one", endpoint: Endpoint{ ProviderSpecific: []ProviderSpecificProperty{ {Name: "first", Value: "invalid"}, {Name: "second", Value: "true"}, {Name: "third", Value: "false"}, }, }, key: "second", expectedValue: true, expectedExists: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { value, exists := tt.endpoint.GetBoolProviderSpecificProperty(tt.key) assert.Equal(t, tt.expectedValue, value) assert.Equal(t, tt.expectedExists, exists) }) } } func TestGetOwnerId(t *testing.T) { tests := []struct { name string endpoint *Endpoint expected string }{ { name: "owner label is set", endpoint: &Endpoint{ Labels: Labels{ OwnerLabelKey: "my-owner", }, }, expected: "my-owner", }, { name: "owner label is empty string", endpoint: &Endpoint{ Labels: Labels{ OwnerLabelKey: "", }, }, expected: "", }, { name: "owner label is not set", endpoint: &Endpoint{ Labels: Labels{ "other-label": "value", }, }, expected: "", }, { name: "labels map is empty", endpoint: &Endpoint{ Labels: Labels{}, }, expected: "", }, { name: "labels map is nil", endpoint: &Endpoint{ Labels: nil, }, expected: "", }, { name: "multiple labels with owner", endpoint: &Endpoint{ Labels: Labels{ OwnerLabelKey: "owner-123", "other-key": "other-value", }, }, expected: "owner-123", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.endpoint.GetOwner() assert.Equal(t, tt.expected, result) }) } } func TestGetNakedDomain(t *testing.T) { tests := []struct { name string endpoint *Endpoint expected string }{ { name: "standard subdomain", endpoint: &Endpoint{ DNSName: "www.example.com", }, expected: "example.com", }, { name: "nested subdomain", endpoint: &Endpoint{ DNSName: "api.v1.example.com", }, expected: "v1.example.com", }, { name: "root domain only", endpoint: &Endpoint{ DNSName: "example.com", }, expected: "example.com", }, { name: "single label (no dots)", endpoint: &Endpoint{ DNSName: "localhost", }, expected: "localhost", }, { name: "empty DNS name", endpoint: &Endpoint{ DNSName: "", }, expected: "", }, { name: "deeply nested subdomain", endpoint: &Endpoint{ DNSName: "a.b.c.d.example.com", }, expected: "b.c.d.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.endpoint.GetNakedDomain() assert.Equal(t, tt.expected, result) }) } } func TestRequestedRecordType(t *testing.T) { ep := NewEndpoint("example.com", RecordTypeA, "1.2.3.4"). WithProviderSpecific(ProviderSpecificRecordType, "ptr") val, ok := ep.RequestedRecordType() assert.True(t, ok) assert.Equal(t, "ptr", val) ep2 := NewEndpoint("example.com", RecordTypeA, "1.2.3.4") _, ok = ep2.RequestedRecordType() assert.False(t, ok) } func TestNewPTREndpoint(t *testing.T) { tests := []struct { name string target string ttl TTL hostnames []string wantName string wantErr bool }{ { name: "IPv4", target: "192.168.49.2", ttl: 300, hostnames: []string{"web.example.com"}, wantName: "2.49.168.192.in-addr.arpa", }, { name: "IPv6", target: "2001:db8::1", ttl: 600, hostnames: []string{"v6.example.com"}, wantName: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", }, { name: "multiple hostnames", target: "10.0.0.1", ttl: 60, hostnames: []string{"a.example.com", "b.example.com"}, wantName: "1.0.0.10.in-addr.arpa", }, { name: "invalid target", target: "not-an-ip", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ep, err := NewPTREndpoint(tt.target, tt.ttl, tt.hostnames...) if tt.wantErr { assert.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.wantName, ep.DNSName) assert.Equal(t, RecordTypePTR, ep.RecordType) assert.Equal(t, tt.ttl, ep.RecordTTL) assert.Equal(t, Targets(tt.hostnames), ep.Targets) }) } } ================================================ FILE: endpoint/labels.go ================================================ /* Copyright 2017 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 endpoint import ( log "github.com/sirupsen/logrus" "errors" "fmt" "sort" "strings" ) // ErrInvalidHeritage is returned when heritage was not found, or different heritage is found var ErrInvalidHeritage = errors.New("heritage is unknown or not found") const ( heritage = "external-dns" // OwnerLabelKey is the name of the label that defines the owner of an Endpoint. OwnerLabelKey = "owner" // ResourceLabelKey is the name of the label that identifies k8s resource which wants to acquire the DNS name ResourceLabelKey = "resource" // OwnedRecordLabelKey is the name of the label that identifies the record that is owned by the labeled TXT registry record OwnedRecordLabelKey = "ownedRecord" // AWSSDDescriptionLabel label responsible for storing raw owner/resource combination information in the Labels // supposed to be inserted by AWS SD Provider, and parsed into OwnerLabelKey and ResourceLabelKey key by AWS SD Registry AWSSDDescriptionLabel = "aws-sd-description" // txtEncryptionNonce label for keep same nonce for same txt records, for prevent different result of encryption for same txt record, it can cause issues for some providers txtEncryptionNonce = "txt-encryption-nonce" ) // Labels store metadata related to the endpoint // it is then stored in a persistent storage via serialization type Labels map[string]string // NewLabels returns empty Labels func NewLabels() Labels { return map[string]string{} } // NewLabelsFromString constructs endpoints labels from a provided format string // if heritage set to another value is found then error is returned // no heritage automatically assumes is not owned by external-dns and returns invalidHeritage error func NewLabelsFromStringPlain(labelText string) (Labels, error) { endpointLabels := map[string]string{} labelText = strings.Trim(labelText, "\"") // drop quotes tokens := strings.Split(labelText, ",") foundExternalDNSHeritage := false for _, token := range tokens { if len(strings.Split(token, "=")) != 2 { continue } key := strings.Split(token, "=")[0] val := strings.Split(token, "=")[1] if key == "heritage" && val != heritage { return nil, ErrInvalidHeritage } if key == "heritage" { foundExternalDNSHeritage = true continue } if strings.HasPrefix(key, heritage) { endpointLabels[strings.TrimPrefix(key, heritage+"/")] = val } } if !foundExternalDNSHeritage { return nil, ErrInvalidHeritage } return endpointLabels, nil } func NewLabelsFromString(labelText string, aesKey []byte) (Labels, error) { if len(aesKey) != 0 { decryptedText, encryptionNonce, err := DecryptText(strings.Trim(labelText, "\""), aesKey) // in case if we have a decryption error, try process original text // decryption errors should be ignored here, because we can already have plain-text labels in the registry if err == nil { labels, err := NewLabelsFromStringPlain(decryptedText) if err == nil { labels[txtEncryptionNonce] = encryptionNonce } return labels, err } } return NewLabelsFromStringPlain(labelText) } // SerializePlain transforms endpoints labels into a external-dns recognizable format string // withQuotes adds additional quotes func (l Labels) SerializePlain(withQuotes bool) string { var tokens []string tokens = append(tokens, fmt.Sprintf("heritage=%s", heritage)) var keys []string for key := range l { keys = append(keys, key) } sort.Strings(keys) // sort for consistency for _, key := range keys { if key == txtEncryptionNonce { continue } tokens = append(tokens, fmt.Sprintf("%s/%s=%s", heritage, key, l[key])) } if withQuotes { return fmt.Sprintf("\"%s\"", strings.Join(tokens, ",")) } return strings.Join(tokens, ",") } // Serialize same to SerializePlain, but encrypt data, if encryption enabled func (l Labels) Serialize(withQuotes bool, txtEncryptEnabled bool, aesKey []byte) string { if !txtEncryptEnabled { return l.SerializePlain(withQuotes) } encryptionNonce, ok := l[txtEncryptionNonce] if !ok { var err error encryptionNonce, err = GenerateNonce() if err != nil { log.Fatalf("Failed to generate cryptographic nonce: %v", err) } l[txtEncryptionNonce] = encryptionNonce } text := l.SerializePlain(false) log.Debugf("Encrypt the serialized text %#v before returning it.", text) var err error text, err = EncryptText(text, aesKey, encryptionNonce) if err != nil { // TODO: review if we could return error instead of crashing the external-dns // if encryption failed, the external-dns will crash log.Fatalf("Failed to encrypt the text: %v", err) } if withQuotes { text = fmt.Sprintf("\"%s\"", text) } log.Debugf("Serialized text after encryption is %#v.", text) return text } ================================================ FILE: endpoint/labels_test.go ================================================ /* Copyright 2017 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 endpoint import ( "bytes" "crypto/rand" "fmt" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/suite" ) type LabelsSuite struct { suite.Suite aesKey []byte foo Labels fooAsText string fooAsTextWithQuotes string fooAsTextEncrypted string fooAsTextWithQuotesEncrypted string barText string barTextEncrypted string barTextAsMap Labels noHeritageText string wrongHeritageText string multipleHeritageText string // considered invalid } func (suite *LabelsSuite) SetupTest() { suite.foo = map[string]string{ "owner": "foo-owner", "resource": "foo-resource", } suite.aesKey = []byte(")K_Fy|?Z.64#UuHm`}[d!GC%WJM_fs{_") suite.fooAsText = "heritage=external-dns,external-dns/owner=foo-owner,external-dns/resource=foo-resource" suite.fooAsTextWithQuotes = fmt.Sprintf(`"%s"`, suite.fooAsText) suite.fooAsTextEncrypted = `+lvP8q9KHJ6BS6O81i2Q6DLNdf2JSKy8j/gbZKviTZlGYj7q+yDoYMgkQ1hPn6urtGllM5bfFMcaaHto52otQtiOYrX8990J3kQqg4s47m3bH3Ejl8RSxSSuWJM3HJtPghQzYg0/LSOsdQ0=` suite.fooAsTextWithQuotesEncrypted = fmt.Sprintf(`"%s"`, suite.fooAsTextEncrypted) suite.barTextAsMap = map[string]string{ "owner": "bar-owner", "resource": "bar-resource", "new-key": "bar-new-key", } suite.barText = "heritage=external-dns,,external-dns/owner=bar-owner,external-dns/resource=bar-resource,external-dns/new-key=bar-new-key,random=stuff,no-equal-sign,," // also has some random gibberish suite.barTextEncrypted = "yi6vVATlgYN0enXBIupVK2atNUKtajofWMroWtvZjUanFZXlWvqjJPpjmMd91kv86bZj+syQEP0uR3TK6eFVV7oKFh/NxYyh238FjZ+25zlXW9TgbLoMalUNOkhKFdfXkLeeaqJjePB59t+kQBYX+ZEryK652asPs6M+xTIvtg07N7WWZ6SjJujm0RRISg==" suite.noHeritageText = "external-dns/owner=random-owner" suite.wrongHeritageText = "heritage=mate,external-dns/owner=random-owner" suite.multipleHeritageText = "heritage=mate,heritage=external-dns,external-dns/owner=random-owner" } func (suite *LabelsSuite) TestSerialize() { suite.Equal(suite.fooAsText, suite.foo.SerializePlain(false), "should serializeLabel") suite.Equal(suite.fooAsTextWithQuotes, suite.foo.SerializePlain(true), "should serializeLabel") suite.Equal(suite.fooAsText, suite.foo.Serialize(false, false, nil), "should serializeLabel") suite.Equal(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, false, nil), "should serializeLabel") suite.Equal(suite.fooAsText, suite.foo.Serialize(false, false, suite.aesKey), "should serializeLabel") suite.Equal(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, false, suite.aesKey), "should serializeLabel") suite.NotEqual(suite.fooAsText, suite.foo.Serialize(false, true, suite.aesKey), "should serializeLabel and encrypt") suite.NotEqual(suite.fooAsTextWithQuotes, suite.foo.Serialize(true, true, suite.aesKey), "should serializeLabel and encrypt") } func (suite *LabelsSuite) TestEncryptionNonceReUsage() { foo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey) suite.NoError(err, "should succeed for valid label text") serialized := foo.Serialize(false, true, suite.aesKey) suite.Equal(serialized, suite.fooAsTextEncrypted, "serialized result should be equal") } func (suite *LabelsSuite) TestEncryptionKeyChanged() { foo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey) suite.NoError(err, "should succeed for valid label text") serialised := foo.Serialize(false, true, []byte("passphrasewhichneedstobe32bytes!")) suite.NotEqual(serialised, suite.fooAsTextEncrypted, "serialized result should be equal") } func (suite *LabelsSuite) TestEncryptionFailed() { foo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey) suite.NoError(err, "should succeed for valid label text") defer func() { log.StandardLogger().ExitFunc = nil }() b := new(bytes.Buffer) var fatalCrash bool log.StandardLogger().ExitFunc = func(int) { fatalCrash = true } log.StandardLogger().SetOutput(b) _ = foo.Serialize(false, true, []byte("wrong-key")) suite.True(fatalCrash, "should fail if encryption key is wrong") suite.Contains(b.String(), "Failed to encrypt the text:") } func (suite *LabelsSuite) TestEncryptionFailedFaultyReader() { foo, err := NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey) suite.NoError(err, "should succeed for valid label text") // remove encryption nonce just for simplicity, so that we could regenerate nonce delete(foo, txtEncryptionNonce) originalRandReader := rand.Reader defer func() { log.StandardLogger().ExitFunc = nil rand.Reader = originalRandReader }() // Replace rand.Reader with a faulty reader rand.Reader = &faultyReader{} b := new(bytes.Buffer) var fatalCrash bool log.StandardLogger().ExitFunc = func(int) { fatalCrash = true } log.StandardLogger().SetOutput(b) _ = foo.Serialize(false, true, suite.aesKey) suite.True(fatalCrash) suite.Contains(b.String(), "Failed to generate cryptographic nonce") } func (suite *LabelsSuite) TestDeserialize() { foo, err := NewLabelsFromStringPlain(suite.fooAsText) suite.NoError(err, "should succeed for valid label text") suite.Equal(suite.foo, foo, "should reconstruct original label map") foo, err = NewLabelsFromStringPlain(suite.fooAsTextWithQuotes) suite.NoError(err, "should succeed for valid label text") suite.Equal(suite.foo, foo, "should reconstruct original label map") foo, err = NewLabelsFromString(suite.fooAsTextEncrypted, suite.aesKey) suite.NoError(err, "should succeed for valid encrypted label text") for key, val := range suite.foo { suite.Equal(val, foo[key], "should contains all keys from original label map") } foo, err = NewLabelsFromString(suite.fooAsTextWithQuotesEncrypted, suite.aesKey) suite.NoError(err, "should succeed for valid encrypted label text") for key, val := range suite.foo { suite.Equal(val, foo[key], "should contains all keys from original label map") } bar, err := NewLabelsFromStringPlain(suite.barText) suite.NoError(err, "should succeed for valid label text") suite.Equal(suite.barTextAsMap, bar, "should reconstruct original label map") bar, err = NewLabelsFromString(suite.barText, suite.aesKey) suite.NoError(err, "should succeed for valid encrypted label text") suite.Equal(suite.barTextAsMap, bar, "should reconstruct original label map") noHeritage, err := NewLabelsFromStringPlain(suite.noHeritageText) suite.Equal(ErrInvalidHeritage, err, "should fail if no heritage is found") suite.Nil(noHeritage, "should return nil") wrongHeritage, err := NewLabelsFromStringPlain(suite.wrongHeritageText) suite.Equal(ErrInvalidHeritage, err, "should fail if wrong heritage is found") suite.Nil(wrongHeritage, "if error should return nil") multipleHeritage, err := NewLabelsFromStringPlain(suite.multipleHeritageText) suite.Equal(ErrInvalidHeritage, err, "should fail if multiple heritage is found") suite.Nil(multipleHeritage, "if error should return nil") } func TestLabels(t *testing.T) { suite.Run(t, new(LabelsSuite)) } ================================================ FILE: endpoint/target_filter.go ================================================ /* Copyright 2017 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 endpoint import ( "net" "strings" log "github.com/sirupsen/logrus" ) // TargetFilterInterface defines the interface to select matching targets for a specific provider or runtime type TargetFilterInterface interface { Match(target string) bool IsEnabled() bool } // TargetNetFilter holds a lists of valid target names type TargetNetFilter struct { // filterNets define what targets to match filterNets []*net.IPNet // excludeNets define what targets not to match excludeNets []*net.IPNet } // prepareTargetFilters provides consistent trimming for filters/exclude params func prepareTargetFilters(filters []string) []*net.IPNet { fs := make([]*net.IPNet, 0) for _, filter := range filters { filter = strings.TrimSpace(filter) _, filterNet, err := net.ParseCIDR(filter) if err != nil { log.Errorf("Invalid target net filter: %s", filter) continue } fs = append(fs, filterNet) } return fs } // NewTargetNetFilterWithExclusions returns a new TargetNetFilter, given a list of matches and exclusions func NewTargetNetFilterWithExclusions(targetFilterNets []string, excludeNets []string) TargetNetFilter { return TargetNetFilter{filterNets: prepareTargetFilters(targetFilterNets), excludeNets: prepareTargetFilters(excludeNets)} } // Match checks whether a target can be found in the TargetNetFilter. func (tf TargetNetFilter) Match(target string) bool { return matchTargetNetFilter(tf.filterNets, target, true) && !matchTargetNetFilter(tf.excludeNets, target, false) } // IsEnabled returns true if any filters or exclusions are set. func (tf TargetNetFilter) IsEnabled() bool { return len(tf.filterNets) > 0 || len(tf.excludeNets) > 0 } // matchTargetNetFilter determines if any `filters` match `target`. // If no `filters` are provided, behavior depends on `emptyval` // (empty `tf.filters` matches everything, while empty `tf.exclude` excludes nothing) func matchTargetNetFilter(filters []*net.IPNet, target string, emptyval bool) bool { if len(filters) == 0 { return emptyval } ip := net.ParseIP(target) for _, filter := range filters { if filter.Contains(ip) { return true } } return false } ================================================ FILE: endpoint/target_filter_test.go ================================================ /* Copyright 2017 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 endpoint import ( "testing" "github.com/stretchr/testify/assert" ) type targetFilterTest struct { targetFilter []string exclusions []string targets []string expected bool } var targetFilterTests = []targetFilterTest{ { []string{"10.0.0.0/8"}, []string{}, []string{"10.1.2.3"}, true, }, { []string{" 10.0.0.0/8 "}, []string{}, []string{"10.1.2.3"}, true, }, { []string{"0"}, []string{}, []string{"10.1.2.3"}, true, }, { []string{"10.0.0.0/8"}, []string{}, []string{"1.1.1.1"}, false, }, { []string{}, []string{"10.0.0.0/8"}, []string{"1.1.1.1"}, true, }, { []string{}, []string{"10.0.0.0/8"}, []string{"10.1.2.3"}, false, }, { []string{}, []string{"10.0.0.0/8"}, []string{"49.13.41.161"}, true, }, { []string{}, []string{"10.0.0.0/8"}, []string{"10.0.1.101"}, false, }, } func TestTargetFilterWithExclusions(t *testing.T) { for i, tt := range targetFilterTests { if len(tt.exclusions) == 0 { tt.exclusions = append(tt.exclusions, "") } targetFilter := NewTargetNetFilterWithExclusions(tt.targetFilter, tt.exclusions) for _, target := range tt.targets { assert.Equal(t, tt.expected, targetFilter.Match(target), "should not fail: %v in test-case #%v", target, i) } } } func TestTargetFilterMatchWithEmptyFilter(t *testing.T) { for _, tt := range targetFilterTests { targetFilter := TargetNetFilter{} for i, target := range tt.targets { assert.True(t, targetFilter.Match(target), "should not fail: %v in test-case #%v", target, i) } } } func TestTargetNetFilter_IsEnabled(t *testing.T) { tests := []struct { name string filterNets []string excludeNets []string want bool }{ {"both empty", []string{}, []string{}, false}, {"filterNets non-empty", []string{"10.0.0.0/8"}, []string{}, true}, {"excludeNets non-empty", []string{}, []string{"10.0.0.0/8"}, true}, {"both non-empty", []string{"10.0.0.0/8"}, []string{"192.168.0.0/16"}, true}, } for _, tt := range tests { tf := NewTargetNetFilterWithExclusions(tt.filterNets, tt.excludeNets) assert.Equal(t, tt.want, tf.IsEnabled()) } } ================================================ FILE: endpoint/utils.go ================================================ /* Copyright 2026 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 endpoint import ( "net/netip" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/pkg/events" ) const ( msg = "No endpoints could be generated from '%s/%s/%s'" ) // SuitableType returns the DNS record type for the given target: // A for IPv4, AAAA for IPv6, CNAME for everything else. func SuitableType(target string) string { ip, err := netip.ParseAddr(target) if err != nil { return RecordTypeCNAME } switch { case ip.Is4(): return RecordTypeA case ip.Is6(): return RecordTypeAAAA default: return RecordTypeCNAME } } // HasNoEmptyEndpoints checks if the endpoint list is empty and logs // a debug message if so. Returns true if empty, false otherwise. func HasNoEmptyEndpoints( endpoints []*Endpoint, rType string, entity metav1.ObjectMetaAccessor, ) bool { if len(endpoints) == 0 { log.Debugf(msg, rType, entity.GetObjectMeta().GetNamespace(), entity.GetObjectMeta().GetName()) return true } return false } // EndpointsForHostname returns endpoint objects for each host-target combination, // grouping targets by their suitable DNS record type (A, AAAA, or CNAME). func EndpointsForHostname(hostname string, targets Targets, ttl TTL, providerSpecific ProviderSpecific, setIdentifier string, resource string) []*Endpoint { byType := map[string]Targets{} for _, t := range targets { rt := SuitableType(t) byType[rt] = append(byType[rt], t) } var endpoints []*Endpoint for _, rt := range []string{RecordTypeA, RecordTypeAAAA, RecordTypeCNAME} { if len(byType[rt]) == 0 { continue } ep := NewEndpointWithTTL(hostname, rt, ttl, byType[rt]...) if ep == nil { continue } ep.ProviderSpecific = providerSpecific ep.SetIdentifier = setIdentifier if resource != "" { ep.Labels[ResourceLabelKey] = resource } endpoints = append(endpoints, ep) } return endpoints } // AttachRefObject sets the same ObjectReference on every endpoint in eps. // The reference is shared across all endpoints, so callers should create it once // per source object rather than once per endpoint. func AttachRefObject(eps []*Endpoint, ref *events.ObjectReference) { for _, ep := range eps { ep.WithRefObject(ref) } } ================================================ FILE: endpoint/utils_test.go ================================================ /* Copyright 2026 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 endpoint import ( "testing" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type mockObjectMetaAccessor struct { namespace string name string } func (m *mockObjectMetaAccessor) GetObjectMeta() metav1.Object { return &metav1.ObjectMeta{ Namespace: m.namespace, Name: m.name, } } func TestSuitableType(t *testing.T) { tests := []struct { target string expected string }{ // IPv4 {"192.168.1.1", RecordTypeA}, {"255.255.255.255", RecordTypeA}, {"0.0.0.0", RecordTypeA}, // IPv6 {"2001:0db8:85a3:0000:0000:8a2e:0370:7334", RecordTypeAAAA}, {"2001:db8:85a3::8a2e:370:7334", RecordTypeAAAA}, {"::ffff:192.168.20.3", RecordTypeAAAA}, // IPv4-mapped IPv6 {"::1", RecordTypeAAAA}, {"::", RecordTypeAAAA}, // CNAME (hostname or invalid) {"example.com", RecordTypeCNAME}, {"", RecordTypeCNAME}, {"256.256.256.256", RecordTypeCNAME}, {"192.168.0.1/22", RecordTypeCNAME}, {"192.168.1", RecordTypeCNAME}, {"abc.def.ghi.jkl", RecordTypeCNAME}, } for _, tt := range tests { t.Run(tt.target, func(t *testing.T) { assert.Equal(t, tt.expected, SuitableType(tt.target)) }) } } func TestHasEmptyEndpoints(t *testing.T) { tests := []struct { name string endpoints []*Endpoint rType string entity metav1.ObjectMetaAccessor expected bool }{ { name: "nil endpoints returns true", endpoints: nil, rType: "Service", entity: &mockObjectMetaAccessor{namespace: "default", name: "my-service"}, expected: true, }, { name: "empty slice returns true", endpoints: []*Endpoint{}, rType: "Ingress", entity: &mockObjectMetaAccessor{namespace: "kube-system", name: "my-ingress"}, expected: true, }, { name: "single endpoint returns false", endpoints: []*Endpoint{ NewEndpoint("example.org", "A", "1.2.3.4"), }, rType: "Service", entity: &mockObjectMetaAccessor{namespace: "default", name: "my-service"}, expected: false, }, { name: "multiple endpoints returns false", endpoints: []*Endpoint{ NewEndpoint("example.org", "A", "1.2.3.4"), NewEndpoint("test.example.org", "CNAME", "example.org"), }, rType: "Ingress", entity: &mockObjectMetaAccessor{namespace: "production", name: "frontend"}, expected: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := HasNoEmptyEndpoints(tc.endpoints, tc.rType, tc.entity) assert.Equal(t, tc.expected, result) // TODO: Add log capture and verification }) } } func TestEndpointsForHostname(t *testing.T) { tests := []struct { name string hostname string targets Targets ttl TTL providerSpecific ProviderSpecific setIdentifier string resource string expected []*Endpoint }{ { name: "A record targets", hostname: "example.com", targets: Targets{"192.0.2.1", "192.0.2.2"}, ttl: TTL(300), providerSpecific: ProviderSpecific{ {Name: "provider", Value: "value"}, }, setIdentifier: "identifier", resource: "resource", expected: []*Endpoint{ { DNSName: "example.com", Targets: Targets{"192.0.2.1", "192.0.2.2"}, RecordType: RecordTypeA, RecordTTL: TTL(300), ProviderSpecific: ProviderSpecific{{Name: "provider", Value: "value"}}, SetIdentifier: "identifier", Labels: map[string]string{ResourceLabelKey: "resource"}, }, }, }, { name: "AAAA record targets", hostname: "example.com", targets: Targets{"2001:db8::1", "2001:db8::2"}, ttl: TTL(300), providerSpecific: ProviderSpecific{ {Name: "provider", Value: "value"}, }, setIdentifier: "identifier", resource: "resource", expected: []*Endpoint{ { DNSName: "example.com", Targets: Targets{"2001:db8::1", "2001:db8::2"}, RecordType: RecordTypeAAAA, RecordTTL: TTL(300), ProviderSpecific: ProviderSpecific{{Name: "provider", Value: "value"}}, SetIdentifier: "identifier", Labels: map[string]string{ResourceLabelKey: "resource"}, }, }, }, { name: "CNAME record targets", hostname: "example.com", targets: Targets{"cname.example.com"}, ttl: TTL(300), providerSpecific: ProviderSpecific{ {Name: "provider", Value: "value"}, }, setIdentifier: "identifier", resource: "resource", expected: []*Endpoint{ { DNSName: "example.com", Targets: Targets{"cname.example.com"}, RecordType: RecordTypeCNAME, RecordTTL: TTL(300), ProviderSpecific: ProviderSpecific{{Name: "provider", Value: "value"}}, SetIdentifier: "identifier", Labels: map[string]string{ResourceLabelKey: "resource"}, }, }, }, { name: "No targets", hostname: "example.com", targets: Targets{}, ttl: TTL(300), providerSpecific: ProviderSpecific{}, setIdentifier: "", resource: "", expected: []*Endpoint(nil), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := EndpointsForHostname(tt.hostname, tt.targets, tt.ttl, tt.providerSpecific, tt.setIdentifier, tt.resource) assert.Equal(t, tt.expected, result) }) } } ================================================ FILE: endpoint/zz_generated.deepcopy.go ================================================ //go:build !ignore_autogenerated // Code generated by controller-gen. DO NOT EDIT. package endpoint // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Endpoint) DeepCopyInto(out *Endpoint) { *out = *in if in.Targets != nil { in, out := &in.Targets, &out.Targets *out = make(Targets, len(*in)) copy(*out, *in) } if in.Labels != nil { in, out := &in.Labels, &out.Labels *out = make(Labels, len(*in)) for key, val := range *in { (*out)[key] = val } } if in.ProviderSpecific != nil { in, out := &in.ProviderSpecific, &out.ProviderSpecific *out = make(ProviderSpecific, len(*in)) copy(*out, *in) } if in.refObject != nil { in, out := &in.refObject, &out.refObject *out = new(ObjectRef) **out = **in } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Endpoint. func (in *Endpoint) DeepCopy() *Endpoint { if in == nil { return nil } out := new(Endpoint) in.DeepCopyInto(out) return out } ================================================ FILE: external-dns.code-workspace ================================================ { "folders": [ { "path": "." } ] } ================================================ FILE: go.mod ================================================ module sigs.k8s.io/external-dns go 1.25.7 require ( cloud.google.com/go/compute/metadata v0.9.0 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 github.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.2 github.com/Yamashou/gqlgenc v0.33.0 github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 github.com/alecthomas/kingpin/v2 v2.4.0 github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.35 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4 github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.39.25 github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 github.com/aws/smithy-go v1.24.2 github.com/bodgit/tsig v1.2.2 github.com/cenkalti/backoff/v5 v5.0.3 github.com/civo/civogo v0.7.0 github.com/cloudflare/cloudflare-go/v5 v5.1.0 github.com/datawire/ambassador v1.12.4 github.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace github.com/dnsimple/dnsimple-go v1.7.0 github.com/exoscale/egoscale v0.102.3 github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99 github.com/go-gandi/go-gandi v0.7.0 github.com/go-logr/logr v1.4.3 github.com/goccy/go-yaml v1.19.2 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/linode/linodego v1.66.0 github.com/maxatome/go-testdeep v1.15.0 github.com/miekg/dns v1.1.72 github.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7 github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 github.com/oracle/oci-go-sdk/v65 v65.109.2 github.com/ovh/go-ovh v1.9.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pluralsh/gqlclient v1.12.2 github.com/projectcontour/contour v1.33.2 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.67.5 github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 github.com/transip/gotransip/v6 v6.26.1 go.etcd.io/etcd/api/v3 v3.6.8 go.etcd.io/etcd/client/v3 v3.6.8 go.uber.org/ratelimit v0.3.1 golang.org/x/net v0.52.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/text v0.35.0 golang.org/x/time v0.15.0 google.golang.org/api v0.272.0 gopkg.in/ns1/ns1-go.v2 v2.17.2 istio.io/api v1.29.1 istio.io/client-go v1.29.1 k8s.io/api v0.35.3 k8s.io/apimachinery v0.35.3 k8s.io/client-go v0.35.3 k8s.io/klog/v2 v2.140.0 k8s.io/utils v0.0.0-20260108192941-914a6e750570 sigs.k8s.io/controller-runtime v0.23.3 sigs.k8s.io/gateway-api v1.5.1 sigs.k8s.io/yaml v1.6.0 ) require ( cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect github.com/99designs/gqlgen v0.17.73 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/benbjohnson/clock v1.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deepmap/oapi-codegen v1.9.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/jsonreference v0.21.4 // indirect github.com/go-openapi/swag v0.25.4 // indirect github.com/go-openapi/swag/cmdutils v0.25.4 // indirect github.com/go-openapi/swag/conv v0.25.4 // indirect github.com/go-openapi/swag/fileutils v0.25.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-openapi/swag/jsonutils v0.25.4 // indirect github.com/go-openapi/swag/loading v0.25.4 // indirect github.com/go-openapi/swag/mangling v0.25.4 // indirect github.com/go-openapi/swag/netutils v0.25.4 // indirect github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/gofrs/flock v0.10.0 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.18.0 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/jcmturner/aescts/v2 v2.0.0 // indirect github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/goidentity/v6 v6.0.1 // indirect github.com/jcmturner/gokrb5/v8 v8.4.3 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b // indirect github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect github.com/peterhellberg/link v1.1.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/schollz/progressbar/v3 v3.8.6 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/sony/gobreaker v0.5.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/vektah/gqlparser/v2 v2.5.26 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect moul.io/http2curl v1.0.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) ================================================ FILE: go.sum ================================================ bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxohYNQpsxXPbPry4JJWOB3LB8= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= git.lukeshu.com/go/libsystemd v0.5.3/go.mod h1:FfDoP0i92r4p5Vn4NCLxvjkd7rCOe6otPa4L6hZg9WM= github.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg= github.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg= github.com/Azure/azure-sdk-for-go v16.2.1+incompatible h1:KnPIugL51v3N3WwvaSmZbxukD1WuWXOiE9fRdu32f2I= github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.4.1/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.2 h1:QyEs7el2INrys40wwF5jIW5+q5uBa7rNsNEsUtroRvw= github.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.2/go.mod h1:tV7L3tfaN0R6z9PmuqacxBsEsFsIzptza00AuJ0fPck= github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc= github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig v2.17.1+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= github.com/Masterminds/sprig/v3 v3.1.0/go.mod h1:ONGMf7UfYGAbMXCZmQLy8x3lCDIPrEZE/rU8pmrbihA= github.com/Masterminds/squirrel v1.2.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA= github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA= github.com/Masterminds/vcs v1.13.1/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA= github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/Yamashou/gqlgenc v0.33.0 h1:0fxTnNE8/JVmFpfo7reA5pEgOcr7VjNc+/nEpVhNjfc= github.com/Yamashou/gqlgenc v0.33.0/go.mod h1:MZGXx/nALyxcehcFeLGmYiNsJ+hQTOGJzNYCGNX4rL0= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 h1:F1j7z+/DKEsYqZNoxC6wvfmaiDneLsQOFQmuq9NADSY= github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2/go.mod h1:QlXr/TrICfQ/ANa76sLeQyhAJyNR9sEcfNuZBkY9jgY= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 h1:P5U+E4x5OkVEKQDklVPmzs71WM56RTTRqV4OrDC//Y4= github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5/go.mod h1:976q2ETgjT2snVCf2ZaBnyBbVoPERGjUz+0sofzEfro= github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU= github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v0.0.0-20190621154722-5f990b63d2d6/go.mod h1:+lx6/Aqd1kLJ1GQfkvOnaZ1WGmLpMpbprPuIOOZX30U= github.com/aokoli/goutils v1.1.0/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.35 h1:CQ2kB9Q4xQ2PDBmn+KCr/pw1DvK7pH6NkR2nl2KV7ng= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.35/go.mod h1:ypTMB9nZhpqfMeRVesGj4dEknIg0YS+aXGtLMidw/Ek= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2 h1:xi/ECwajy2mixviBD7bKAlGGSwzEaFKX2wIhrZt9NGw= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.56.2/go.mod h1:dLREOeW66eVaaGIOi2ZlLHDgkR3nuJ02rd00j0YSlBE= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.13 h1:xQ9dX2jxVm14uNVe0WomcCSza832ytYWt1ZBu2LrBLM= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.13/go.mod h1:D5up2/CMSP4sF8ESBWla6gJvIMySJi8dYYAaED4oTCc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20 h1:ru+seMuylHiNZlvgZei83eD8h37hRjm1XIMOEmcV0BU= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.20/go.mod h1:ihZMtPTKoX/ugQRHbui6zNdSgVYN1KY2Dgwb2d3hXlc= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4 h1:64aYPyHg3RjLvnMMSYQSg7aP+r1WRCPIS9SP9KfHjWg= github.com/aws/aws-sdk-go-v2/service/route53 v1.62.4/go.mod h1:bPSPzWTn9LSX6e0KPp4LlPoaspouZdKAlIdSMdhBBrs= github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.39.25 h1:sOfFkPdRwWVyI4vU3V69Y+F1nR1VXjisXT7ukomUo3Q= github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.39.25/go.mod h1:/MExzmYxZtSpYWqMGwLnAhlyoKXcvRkdLE2ji/Us0kA= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= github.com/blang/semver v3.1.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bodgit/tsig v1.2.2 h1:RgxTCr8UFUHyU4D8Ygb2UtXtS4niw4B6XYYBpgCjl0k= github.com/bodgit/tsig v1.2.2/go.mod h1:rIGNOLZOV/UA03fmCUtEFbpWOrIoaOuETkpaeTvnLF4= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw= github.com/civo/civogo v0.7.0 h1:XY75Ru7MgjEaE9cC14x0/7v/8WQZBW8WkvA5kUHjn0Q= github.com/civo/civogo v0.7.0/go.mod h1:0RNiA3NDI1imXDADWSCtzcHjUCV02E+SnRLoZKKo1wY= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/cloudflare-go/v5 v5.1.0 h1:vvWUtrt5ZPEBFidL2ik64QipXLZmhMBgtRTw4bYvPwE= github.com/cloudflare/cloudflare-go/v5 v5.1.0/go.mod h1:C6OjOlDHOk/g7lXehothXJRFZrSIJMLzOZB2SXQhcjk= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI= github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko= github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/containerd/continuity v0.0.0-20200107194136-26c1120b8d41/go.mod h1:Dq467ZllaHgAtVp4p1xUQWBrFXR9s/wyoTpG8zOJGkY= github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI= github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH3hrUgRmyYrtgEeGWJfWbgcHL9CSRruz2Vqcph0= github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o= github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= github.com/datawire/ambassador v1.12.4 h1:g+agFHayLqETkCgFgEQi9qk4zakE0UAhgK8xVUEcDDI= github.com/datawire/ambassador v1.12.4/go.mod h1:2grBLdYgILzrgTpenDMB5OeyhObIUaT+KwkLkZI1KDE= github.com/datawire/dlib v1.2.0/go.mod h1:t0upKFHApJskdVFH/gyksG5+vMCl0GCKeEZIEJBBv4g= github.com/datawire/pf v0.0.0-20180510150411-31a823f9495a/go.mod h1:H8uUmE8qqo7z9u30MYB9riLyRckPHOPBk9ZdCuH+dQQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= github.com/deepmap/oapi-codegen v1.9.1 h1:yHmEnA7jSTUMQgV+uN02WpZtwHnz2CBW3mZRIxr1vtI= github.com/deepmap/oapi-codegen v1.9.1/go.mod h1:PLqNAhdedP8ttRpBBkzLKU3bp+Fpy+tTgeAMlztR2cw= github.com/deislabs/oras v0.8.1/go.mod h1:Mx0rMSbBNaNfY9hjpccEnxkOqJL6KGjtxNHPLC4G4As= github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= github.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace h1:1SnCTPFh2AADpm7ti864EYaugexyiDFt55BW188+d6k= github.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace/go.mod h1:TK05uvk4XXfK2kdvRwfcZ1NaxjDxmm7H3aQLko0mJxA= github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= github.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY= github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8= github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v0.0.0-20191216044856-a8371794149d/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/ecodia/golang-awaitility v0.0.0-20180710094957-fb55e59708c7/go.mod h1:etn7NbLy5UviLk20XMZbSn/0AigF3Zfx7wwaEZ3fyIk= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/enceve/crypto v0.0.0-20160707101852-34d48bb93815/go.mod h1:wYFFK4LYXbX7j+76mOq7aiC/EAw2S22CrzPHqgsisPw= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.3.0-java.0.20200609174644-bd816e4522c1/go.mod h1:bjmEhrMDubXDd0uKxnWwRmgSsiEv2CkJliIHnj6ETm8= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/exoscale/egoscale v0.102.3 h1:DYqN2ipoLKpiFoprRGQkp2av/Ze7sUYYlGhi1N62tfY= github.com/exoscale/egoscale v0.102.3/go.mod h1:RPf2Gah6up+6kAEayHTQwqapzXlm93f0VQas/UEGU5c= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99 h1:jmwW6QWvUO2OPe22YfgFvBaaZlSr8Rlrac5lZvG6IdM= github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99/go.mod h1:4mP9w9+vYGw2jUx2+2v03IA+phyQQjNRR4AL3uxlNrs= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVBjqR7JHJk0brhHOZYGmfBYOrK0ZhYMEtBr4= github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/getkin/kin-openapi v0.87.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-gandi/go-gandi v0.7.0 h1:gsP33dUspsN1M+ZW9HEgHchK9HiaSkYnltO73RHhSZA= github.com/go-gandi/go-gandi v0.7.0/go.mod h1:9NoYyfWCjFosClPiWjkbbRK5UViaZ4ctpT8/pKSSFlw= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v0.1.0/go.mod h1:tabnROwaDl0UNxkVeFRbY8bwB37GwRv0P8lg6aAiEnk= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= github.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU= github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= github.com/go-openapi/loads v0.19.4/go.mod h1:zZVHonKd8DXyxyw4yfnVjPzBjIQcLt0CCsn0N0ZrQsk= github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= github.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= github.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.9.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= github.com/gobuffalo/flect v0.2.0/go.mod h1:W3K3X9ksuZfir8f/LrfVtWmCDQFfayuylOJ7sz/Fj80= github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs= github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q= github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-json v0.7.8/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k= github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/gddo v0.0.0-20190419222130-af0f2af80721/go.mod h1:xEhNfoBDX1hzLm2Nf80qUvZ2sVwoMZ8d6IE2SrsQfh4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450/go.mod h1:Bk6SMAONeMXrxql8uvOKuAZSu8aM5RUGv+1C6IJaEho= github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995/go.mod h1:lJgMEyOkYFkPcDKwRXegd+iM6E7matEszMG5HhwytU8= github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4= github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI= github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= github.com/gookit/color v1.2.3/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg= github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/iancoleman/strcase v0.0.0-20180726023541-3605ed457bf7/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.3 h1:iTonLeSJOn7MVUtyMT+arAn5AKAPrkilzhGw8wE/Tq8= github.com/jcmturner/gokrb5/v8 v8.4.3/go.mod h1:dqRwJGXznQrzw6cWmyo6kH+E7jksEQG/CyVWsJEsJO0= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.9.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/pgzip v1.2.1/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/labstack/echo/v4 v4.6.3/go.mod h1:Hk5OiHj0kDqmFq7aHe7eDqI7CUhuCrfpupQtLGGLm7A= github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= github.com/lestrrat-go/codegen v1.0.2/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd1VaXrBk9TdM= github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE= github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= github.com/lestrrat-go/jwx v1.2.7/go.mod h1:bw24IXWbavc0R2RsOtpXL7RtMyP589yZ1+L7kd09ZGA= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/linode/linodego v1.66.0 h1:rK8QJFaV53LWOEJvb/evhTg/dP5ElvtuZmx4iv4RJds= github.com/linode/linodego v1.66.0/go.mod h1:12ykGs9qsvxE+OU3SXuW2w+DTruWF35FPlXC7gGk2tU= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lyft/protoc-gen-star v0.4.10/go.mod h1:mE8fbna26u7aEA2QCVvvfBU/ZrPgocG1206xAFPcs94= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/maxatome/go-testdeep v1.15.0 h1:o3ghyKh6Hmy5EnyfQh+8lbH++4gpknmgc8jo7h/AVp8= github.com/maxatome/go-testdeep v1.15.0/go.mod h1:BEC221DXFjTrG2VLzAYYi3xz8aK1QYa391djuaR2jkA= github.com/mholt/archiver/v3 v3.3.0/go.mod h1:YnQtqsp+94Rwd0D/rk5cnLrxusUBUXg+08Ebtr1Mqao= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.6/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= github.com/nats-io/nats-server/v2 v2.1.2/go.mod h1:Afk+wRZqkMQs/p45uXdrVLuab3gwv3Z8C4HTBu8GD/k= github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/olekukonko/tablewriter v0.0.2/go.mod h1:rSAaSIOAGT9odnlyGlUfAJaoc5w2fSBUmeGDbRWPxyQ= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1 h1:mFwc4LvZ0xpSvDZ3E+k8Yte0hLOMxXUlP+yXtJqkYfQ= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= github.com/opencontainers/runtime-spec v0.1.2-0.20190507144316-5b71a03e2700/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mod h1:r3f7wjNzSs2extwzU3Y+6pKfobzPh+kKFJ3ofN+3nfs= github.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7 h1:Ot2fbEEPmF3WlPQkyEW/bUCV38GMugH/UmZvxpWceNc= github.com/openshift/api v0.0.0-20251015095338-264e80a2b6e7/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235 h1:9JBeIXmnHlpXTQPi7LPmu1jdxznBhAE7bb1K+3D8gxY= github.com/openshift/client-go v0.0.0-20251015124057-db0dee36e235/go.mod h1:L49W6pfrZkfOE5iC1PqEkuLkXG4W0BX4w8b+L2Bv7fM= github.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b h1:it0YPE/evO6/m8t8wxis9KFI2F/aleOKsI6d9uz0cEk= github.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b/go.mod h1:tNrEB5k8SI+g5kOlsCmL2ELASfpqEofI0+FLBgBdN08= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxSfWAKL3wpBW7V8scJMt8N8gnaMCS9E/cA= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/oracle/oci-go-sdk/v65 v65.109.2 h1:epzga51qucVjF+8ci2oYYq+mi3cE0DACGmC139WecMM= github.com/oracle/oci-go-sdk/v65 v65.109.2/go.mod h1:8ZzvzuEG/cFLFZhxg/Mg1w19KqyXBKO3c17QIc5PkGs= github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE= github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c= github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/peterhellberg/link v1.1.0 h1:s2+RH8EGuI/mI4QwrWGSYQCRz7uNgip9BaM04HKu5kc= github.com/peterhellberg/link v1.1.0/go.mod h1:gtSlOT4jmkY8P47hbTc8PTgiDDWpdPbFYl75keYyBB8= github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA= github.com/pluralsh/gqlclient v1.12.2 h1:BrEFAASktf4quFw57CIaLAd+NZUTLhG08fe6tnhBQN4= github.com/pluralsh/gqlclient v1.12.2/go.mod h1:OEjN9L63x8m3A3eQBv5kVkFgiY9fp2aZ0cgOF0uII58= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/projectcontour/contour v1.33.2 h1:BFjTltoZPWQkdPejnMrMiCPhSbdQrZDF0GeEFHcr0E4= github.com/projectcontour/contour v1.33.2/go.mod h1:EMmGYpisEQVVA1Edx1IteEwJ0dJqL7yfriBzDKY0zHI= github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= github.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rubenv/sql-migrate v0.0.0-20200212082348-64f95ea68aa3/go.mod h1:rtQlpHw+eR6UrqaS3kX1VYeaCxzCVdimDS7g5Ln4pPc= github.com/rubenv/sql-migrate v0.0.0-20200616145509-8d140a17f351/go.mod h1:DCgfY80j8GYL7MLEfvcpSFvjD0L5yZq/aZUJmhZklyg= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM= github.com/schollz/progressbar/v3 v3.8.6 h1:QruMUdzZ1TbEP++S1m73OqRJk20ON11m6Wqv4EoGg8c= github.com/schollz/progressbar/v3 v3.8.6/go.mod h1:W5IEwbJecncFGBvuEh4A7HT1nZZ6WNIL2i3qbnI0WKY= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg= github.com/sony/gobreaker v0.5.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/transip/gotransip/v6 v6.26.1 h1:MeqIjkTBBsZwWAK6giZyMkqLmKMclVHEuTNmoBdx4MA= github.com/transip/gotransip/v6 v6.26.1/go.mod h1:x0/RWGRK/zob817O3tfO2xhFoP1vu8YOHORx6Jpk80s= github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw= github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= github.com/vektah/gqlparser/v2 v2.5.26 h1:REqqFkO8+SOEgZHR/eHScjjVjGS8Nk3RMO/juiTobN4= github.com/vektah/gqlparser/v2 v2.5.26/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1/go.mod h1:QcJo0QPSfTONNIgpN5RA8prR7fF8nkF6cTWTcNerRO8= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= go.etcd.io/etcd/api/v3 v3.6.8/go.mod h1:qyQj1HZPUV3B5cbAL8scG62+fyz5dSxxu0w8pn28N6Q= go.etcd.io/etcd/client/pkg/v3 v3.6.8 h1:Qs/5C0LNFiqXxYf2GU8MVjYUEXJ6sZaYOz0zEqQgy50= go.etcd.io/etcd/client/pkg/v3 v3.6.8/go.mod h1:GsiTRUZE2318PggZkAo6sWb6l8JLVrnckTNfbG8PWtw= go.etcd.io/etcd/client/v3 v3.6.8 h1:B3G76t1UykqAOrbio7s/EPatixQDkQBevN8/mwiplrY= go.etcd.io/etcd/client/v3 v3.6.8/go.mod h1:MVG4BpSIuumPi+ELF7wYtySETmoTWBHVcDoHdVupwt8= go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200128174031-69ecbb4d6d5d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201217014255-9d1352758620/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220725212005-46097bf591d3/go.mod h1:AaygXjzTFtRAg2ttMY5RMuhpJ3cNnI0XpyFJD1iQRSM= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180425194835-bb9c189858d9/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190514135907-3a4b5fb9f71f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200522201501-cb1345f3a375/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.0.1/go.mod h1:IhYNNY4jnS53ZnfE4PAmpKtDpTCj1JFXc+3mwe7XcUU= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= google.golang.org/api v0.0.0-20160322025152-9bf6e6e569ff/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc= google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20141024133853-64131543e789/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw= gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdODlynE= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/ns1/ns1-go.v2 v2.17.2 h1:x8YKHqCJWkC/hddfUhw7FRqTG0x3fr/0ZnWYN+i4THs= gopkg.in/ns1/ns1-go.v2 v2.17.2/go.mod h1:pfaU0vECVP7DIOr453z03HXS6dFJpXdNRwOyRzwmPSc= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= helm.sh/helm/v3 v3.2.4/go.mod h1:ZaXz/vzktgwjyGGFbUWtIQkscfE7WYoRGP2szqAFHR0= honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= istio.io/api v1.29.1 h1:dSure3CSur+mRZYvTYRUNgR/P+TYO5ItlPk1lUu4rU8= istio.io/api v1.29.1/go.mod h1:+brQWcBHoROuyA6fv8rbgg8Kfn0RCGuqoY0duCMuSLA= istio.io/client-go v1.29.1 h1:GU7/5310KXpNS5vDhDSCYttABpHJddz5tQ+7aBYeJGU= istio.io/client-go v1.29.1/go.mod h1:iCVud7UDjvGbxjqUH+cRk9OruaKdrK69CFDmnV+NOFw= k8s.io/api v0.18.0/go.mod h1:q2HRQkfDzHMBZL9l/y9rH63PkQl4vae0xRT+8prbrK8= k8s.io/api v0.18.2/go.mod h1:SJCWI7OLzhZSvbY7U8zwNl9UA4o1fizoug34OV/2r78= k8s.io/api v0.18.4/go.mod h1:lOIQAKYgai1+vz9J7YcDZwC26Z0zQewYOGWdyIPUUQ4= k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= k8s.io/apiextensions-apiserver v0.18.0/go.mod h1:18Cwn1Xws4xnWQNC00FLq1E350b9lUF+aOdIWDOZxgo= k8s.io/apiextensions-apiserver v0.18.2/go.mod h1:q3faSnRGmYimiocj6cHQ1I3WpLqmDgJFlKL37fC4ZvY= k8s.io/apiextensions-apiserver v0.18.4/go.mod h1:NYeyeYq4SIpFlPxSAB6jHPIdvu3hL0pc36wuRChybio= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= k8s.io/apimachinery v0.18.0/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA= k8s.io/apimachinery v0.18.2/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA= k8s.io/apimachinery v0.18.4/go.mod h1:OaXp26zu/5J7p0f92ASynJa1pZo06YlV9fG7BoWbCko= k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/apiserver v0.18.0/go.mod h1:3S2O6FeBBd6XTo0njUrLxiqk8GNy6wWOftjhJcXYnjw= k8s.io/apiserver v0.18.2/go.mod h1:Xbh066NqrZO8cbsoenCwyDJ1OSi8Ag8I2lezeHxzwzw= k8s.io/apiserver v0.18.4/go.mod h1:q+zoFct5ABNnYkGIaGQ3bcbUNdmPyOCoEBcg51LChY8= k8s.io/cli-runtime v0.18.0/go.mod h1:1eXfmBsIJosjn9LjEBUd2WVPoPAY9XGTqTFcPMIBsUQ= k8s.io/cli-runtime v0.18.4/go.mod h1:9/hS/Cuf7NVzWR5F/5tyS6xsnclxoPLVtwhnkJG1Y4g= k8s.io/client-go v0.18.0/go.mod h1:uQSYDYs4WhVZ9i6AIoEZuwUggLVEF64HOD37boKAtF8= k8s.io/client-go v0.18.2/go.mod h1:Xcm5wVGXX9HAA2JJ2sSBUn3tCJ+4SVlCbl2MNNv+CIU= k8s.io/client-go v0.18.4/go.mod h1:f5sXwL4yAZRkAtzOxRWUhA/N8XzGCb+nPZI8PfobZ9g= k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg= k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c= k8s.io/code-generator v0.18.0/go.mod h1:+UHX5rSbxmR8kzS+FAv7um6dtYrZokQvjHpDSYRVkTc= k8s.io/code-generator v0.18.2/go.mod h1:+UHX5rSbxmR8kzS+FAv7um6dtYrZokQvjHpDSYRVkTc= k8s.io/code-generator v0.18.4/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c= k8s.io/component-base v0.18.0/go.mod h1:u3BCg0z1uskkzrnAKFzulmYaEpZF7XC9Pf/uFyb1v2c= k8s.io/component-base v0.18.2/go.mod h1:kqLlMuhJNHQ9lz8Z7V5bxUUtjFZnrypArGl58gmDfUM= k8s.io/component-base v0.18.4/go.mod h1:7jr/Ef5PGmKwQhyAz/pjByxJbC58mhKAhiaDu0vXfPk= k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/helm v2.16.9+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/kubectl v0.18.0/go.mod h1:LOkWx9Z5DXMEg5KtOjHhRiC1fqJPLyCr3KtQgEolCkU= k8s.io/kubectl v0.18.4/go.mod h1:EzB+nfeUWk6fm6giXQ8P4Fayw3dsN+M7Wjy23mTRtB0= k8s.io/kubernetes v1.13.0/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= k8s.io/metrics v0.18.0/go.mod h1:8aYTW18koXqjLVKL7Ds05RPMX9ipJZI3mywYvBOxXd4= k8s.io/metrics v0.18.4/go.mod h1:luze4fyI9JG4eLDZy0kFdYEebqNfi0QrG4xNEbPkHOs= k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= k8s.io/utils v0.0.0-20200603063816-c1c6865ac451/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8= moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE= rsc.io/letsencrypt v0.0.3/go.mod h1:buyQKZ6IXrRnB7TdkHP0RyEybLx18HHyOSoTyoOLqNY= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.7/go.mod h1:PHgbrJT7lCHcxMU+mDHEm+nx46H4zuuHZkDP6icnhu0= sigs.k8s.io/controller-runtime v0.6.1/go.mod h1:XRYBPdbf5XJu9kpS84VJiZ7h/u1hF3gEORz0efEja7A= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/controller-tools v0.3.1-0.20200517180335-820a4a27ea84/go.mod h1:enhtKGfxZD1GFEoMgP8Fdbu+uKQ/cq1/WGJhdVChfvI= sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kustomize v2.0.3+incompatible/go.mod h1:MkjgH3RdOWrievjo6c9T245dYlB5QeXV4WCbnt/PEpU= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= vbom.ml/util v0.0.0-20160121211510-db5cfe13f5cc/go.mod h1:so/NYdZXCz+E3ZpW0uAoCj6uzU2+8OWDFv/HxUSs7kI= ================================================ FILE: go.tool.mod ================================================ module sigs.k8s.io/external-dns/tools go 1.25.7 tool ( github.com/google/yamlfmt/cmd/yamlfmt github.com/mikefarah/yq/v4 sigs.k8s.io/controller-tools/cmd/controller-gen ) require ( github.com/a8m/envsubst v1.4.3 // indirect github.com/agext/levenshtein v1.2.1 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/alecthomas/participle/v2 v2.1.4 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 // indirect github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect github.com/charmbracelet/glamour v0.10.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/input v0.3.7 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/windows v0.2.1 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 // indirect github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap v1.8.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/yamlfmt v0.21.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/hashicorp/hcl/v2 v2.24.0 // indirect github.com/iancoleman/strcase v0.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mikefarah/yq/v4 v4.52.4 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sasha-s/go-deadlock v0.3.5 // indirect github.com/segmentio/ksuid v1.0.4 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tliron/commonlog v0.2.19 // indirect github.com/tliron/glsp v0.2.2 // indirect github.com/tliron/kutil v0.3.26 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zclconf/go-cty v1.17.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.35.0 // indirect k8s.io/apiextensions-apiserver v0.35.0 // indirect k8s.io/apimachinery v0.35.0 // indirect k8s.io/code-generator v0.35.0 // indirect k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/controller-tools v0.20.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) ================================================ FILE: go.tool.sum ================================================ github.com/a8m/envsubst v1.4.3 h1:kDF7paGK8QACWYaQo6KtyYBozY2jhQrTuNNuUxQkhJY= github.com/a8m/envsubst v1.4.3/go.mod h1:4jjHWQlZoaXPoLQUb7H2qT4iLkZDdmEQiOUogdUmqVU= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA= github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis= github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/input v0.3.7 h1:UzVbkt1vgM9dBQ+K+uRolBlN6IF2oLchmPKKo/aucXo= github.com/charmbracelet/x/input v0.3.7/go.mod h1:ZSS9Cia6Cycf2T6ToKIOxeTBTDwl25AGwArJuGaOBH8= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I= github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM= github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 h1:fuHXpEVTTk7TilRdfGRLHpiTD6tnT0ihEowCfWjlFvw= github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap v1.8.0 h1:TrOREecvh3JbS+NCgwposXG5ZTFHtEsQiCGOhPElnMw= github.com/elliotchance/orderedmap v1.8.0/go.mod h1:wsDwEaX5jEoyhbs7x93zk2H/qv0zwuhg4inXhDkYqys= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4= github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/yamlfmt v0.17.2 h1:TkXxhmj7dnpmOnlWGOXog92Gs6MWcTZqnf3kuyp8yFQ= github.com/google/yamlfmt v0.17.2/go.mod h1:gs0UEklJOYkUJ+OOCG0hg9n+DzucKDPlJElTUasVNK8= github.com/google/yamlfmt v0.21.0 h1:9FKApQkDpMKgBjwLFytBHUCgqnQgxaQnci0uiESfbzs= github.com/google/yamlfmt v0.21.0/go.mod h1:q6FYExB+Ueu7jZDjKECJk+EaeDXJzJ6Ne0dxx69GWfI= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mikefarah/yq/v4 v4.47.2 h1:Jb5fHlvgK5eeaPbreG9UJs1E5w6l5hUzXjeaY6LTTWY= github.com/mikefarah/yq/v4 v4.47.2/go.mod h1:ulYbZUzGJsBDDwO5ohvk/KOW4vW5Iddd/DBeAY1Q09g= github.com/mikefarah/yq/v4 v4.50.1 h1:u7pnei4FIv4HGL5ZBuNVDhDBe9et1YRFnoTmKZw6zOY= github.com/mikefarah/yq/v4 v4.50.1/go.mod h1:L4Z8NywrquZ+PVMz6IFFeGIp64eBC2mGC0nMryygCnI= github.com/mikefarah/yq/v4 v4.52.4 h1:wZlxBMjyKCzzQjL0u6a3zToKuyE7OdJr4OtLBtwph4Q= github.com/mikefarah/yq/v4 v4.52.4/go.mod h1:8QwgSgDsmt4LCbfwvGUAh5oWSukRRuVJ8Gj98zJ/45o= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb h1:3PrKuO92dUTMrQ9dx0YNejC6U/Si6jqKmyQ9vWjwqR4= github.com/petermattis/goid v0.0.0-20250508124226-395b08cebbdb/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/sourcegraph/jsonrpc2 v0.2.0 h1:KjN/dC4fP6aN9030MZCJs9WQbTOjWHhrtKVpzzSrr/U= github.com/sourcegraph/jsonrpc2 v0.2.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tliron/commonlog v0.2.19 h1:v1mOH1TyzFLqkshR03khw7ENAZPjAyZTQBQrqN+vX9c= github.com/tliron/commonlog v0.2.19/go.mod h1:AcdhfcUqlAWukDrzTGyaPhUgYiNdZhS4dKzD/e0tjcY= github.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c= github.com/tliron/glsp v0.2.2/go.mod h1:GMVWDNeODxHzmDPvYbYTCs7yHVaEATfYtXiYJ9w1nBg= github.com/tliron/kutil v0.3.26 h1:G+dicQLvzm3zdOMrrQFLBfHJXtk57fEu2kf1IFNyJxw= github.com/tliron/kutil v0.3.26/go.mod h1:1/HRVAb+fnRIRnzmhu0FPP+ZJKobrpwHStDVMuaXDzY= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 h1:FemxDzfMUcK2f3YY4H+05K9CDzbSVr2+q/JKN45pey0= golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I= golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 h1:6D+BvnJ/j6e222UW8s2qTSe3wGBtvo0MbVQG/c5k8RE= gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473/go.mod h1:N1eN2tsCx0Ydtgjl4cqmbRCsY4/+z4cYDeqwZTk6zog= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/code-generator v0.34.0 h1:Ze2i1QsvUprIlX3oHiGv09BFQRLCz+StA8qKwwFzees= k8s.io/code-generator v0.34.0/go.mod h1:Py2+4w2HXItL8CGhks8uI/wS3Y93wPKO/9mBQUYNua0= k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f h1:SLb+kxmzfA87x4E4brQzB33VBbT2+x7Zq9ROIHmGn9Q= k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ= k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-tools v0.17.2 h1:jNFOKps8WnaRKZU2R+4vRCHnXyJanVmXBWqkuUPFyFg= sigs.k8s.io/controller-tools v0.17.2/go.mod h1:4q5tZG2JniS5M5bkiXY2/potOiXyhoZVw/U48vLkXk0= sigs.k8s.io/controller-tools v0.19.0 h1:OU7jrPPiZusryu6YK0jYSjPqg8Vhf8cAzluP9XGI5uk= sigs.k8s.io/controller-tools v0.19.0/go.mod h1:y5HY/iNDFkmFla2CfQoVb2AQXMsBk4ad84iR1PLANB0= sigs.k8s.io/controller-tools v0.20.0 h1:VWZF71pwSQ2lZZCt7hFGJsOfDc5dVG28/IysjjMWXL8= sigs.k8s.io/controller-tools v0.20.0/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= sigs.k8s.io/controller-tools v0.20.1 h1:gkfMt9YodI0K85oT8rVi80NTXO/kDmabKR5Ajn5GYxs= sigs.k8s.io/controller-tools v0.20.1/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: internal/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - internal ================================================ FILE: internal/config/config.go ================================================ /* Copyright 2020 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 config // FastPoll used for fast testing var FastPoll = false ================================================ FILE: internal/flags/binders.go ================================================ /* 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 flags import ( "fmt" "regexp" "strconv" "time" "github.com/alecthomas/kingpin/v2" ) // FlagBinder abstracts flag registration for different CLI backends. type FlagBinder interface { StringVar(name, help, def string, target *string) BoolVar(name, help string, def bool, target *bool) DurationVar(name, help string, def time.Duration, target *time.Duration) IntVar(name, help string, def int, target *int) Int64Var(name, help string, def int64, target *int64) StringsVar(name, help string, def []string, target *[]string) EnumVar(name, help, def string, target *string, allowed ...string) // StringsEnumVar binds a repeatable string flag with an allowed set. // Implementations may not enforce allowed values. StringsEnumVar(name, help string, def []string, target *[]string, allowed ...string) // StringMapVar binds key=value repeatable flags into a map. StringMapVar(name, help string, target *map[string]string) // RegexpVar binds a regular expression value. RegexpVar(name, help string, def *regexp.Regexp, target **regexp.Regexp) } // KingpinBinder implements FlagBinder using github.com/alecthomas/kingpin/v2. type KingpinBinder struct { App *kingpin.Application } // NewKingpinBinder creates a FlagBinder backed by a kingpin Application. func NewKingpinBinder(app *kingpin.Application) *KingpinBinder { return &KingpinBinder{App: app} } func (b *KingpinBinder) StringVar(name, help, def string, target *string) { b.App.Flag(name, help).Default(def).StringVar(target) } func (b *KingpinBinder) BoolVar(name, help string, def bool, target *bool) { if def { b.App.Flag(name, help).Default("true").BoolVar(target) } else { b.App.Flag(name, help).Default("false").BoolVar(target) } } func (b *KingpinBinder) DurationVar(name, help string, def time.Duration, target *time.Duration) { b.App.Flag(name, help).Default(def.String()).DurationVar(target) } func (b *KingpinBinder) IntVar(name, help string, def int, target *int) { b.App.Flag(name, help).Default(strconv.Itoa(def)).IntVar(target) } func (b *KingpinBinder) Int64Var(name, help string, def int64, target *int64) { b.App.Flag(name, help).Default(strconv.FormatInt(def, 10)).Int64Var(target) } func (b *KingpinBinder) StringsVar(name, help string, def []string, target *[]string) { if len(def) > 0 { b.App.Flag(name, help).Default(def...).StringsVar(target) return } b.App.Flag(name, help).StringsVar(target) } func (b *KingpinBinder) EnumVar(name, help, def string, target *string, allowed ...string) { b.App.Flag(name, help).Default(def).EnumVar(target, allowed...) } func (b *KingpinBinder) StringsEnumVar(name, help string, def []string, target *[]string, allowed ...string) { if len(def) > 0 { b.App.Flag(name, help).Default(def...).EnumsVar(target, allowed...) return } b.App.Flag(name, help).EnumsVar(target, allowed...) } func (b *KingpinBinder) StringMapVar(name, help string, target *map[string]string) { b.App.Flag(name, help).StringMapVar(target) } func (b *KingpinBinder) RegexpVar(name, help string, def *regexp.Regexp, target **regexp.Regexp) { defStr := "" if def != nil { defStr = def.String() } b.App.Flag(name, help).Default(defStr).RegexpVar(target) } type regexpValue struct { target **regexp.Regexp } func (rv *regexpValue) String() string { if rv == nil || rv.target == nil || *rv.target == nil { return "" } return (*rv.target).String() } func (rv *regexpValue) Set(s string) error { re, err := regexp.Compile(s) if err != nil { return err } *rv.target = re return nil } func (rv *regexpValue) Type() string { return "regexp" } type regexpSetter interface { Set(string) error } func setRegexpDefault(rs regexpSetter, def *regexp.Regexp, name string) { if def != nil { if err := rs.Set(def.String()); err != nil { panic(fmt.Errorf("invalid default regexp for flag %s: %w", name, err)) } } } ================================================ FILE: internal/flags/binders_test.go ================================================ /* 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 flags import ( "errors" "regexp" "testing" "time" "github.com/alecthomas/kingpin/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type badSetter struct{} func (b *badSetter) Set(_ string) error { return errors.New("bad default") } func TestKingpinBinderParsesAllTypes(t *testing.T) { app := kingpin.New("test", "") b := NewKingpinBinder(app) var ( s string bval bool d time.Duration i int i64 int64 ss []string e string ) b.StringVar("s", "string flag", "def", &s) b.BoolVar("b", "bool flag", true, &bval) b.DurationVar("d", "duration flag", 5*time.Second, &d) b.IntVar("i", "int flag", 7, &i) b.Int64Var("i64", "int64 flag", 9, &i64) b.StringsVar("ss", "strings flag", []string{"x"}, &ss) b.EnumVar("e", "enum flag", "a", &e, "a", "b") _, err := app.Parse([]string{"--s=abc", "--no-b", "--d=2s", "--i=42", "--i64=64", "--ss=one", "--ss=two", "--e=b"}) require.NoError(t, err) assert.Equal(t, "abc", s) assert.False(t, bval) assert.Equal(t, 2*time.Second, d) assert.Equal(t, 42, i) assert.Equal(t, int64(64), i64) assert.ElementsMatch(t, []string{"one", "two"}, ss) assert.Equal(t, "b", e) } func TestKingpinBinderEnumValidation(t *testing.T) { app := kingpin.New("test", "") b := NewKingpinBinder(app) var e string b.EnumVar("e", "enum flag", "a", &e, "a", "b") _, err := app.Parse([]string{"--e=c"}) require.Error(t, err) } func TestKingpinBinderStringsVarNoDefaultAndBoolDefaultFalse(t *testing.T) { app := kingpin.New("test", "") b := NewKingpinBinder(app) var ( ss []string b2 bool ) b.StringsVar("ss", "strings flag", nil, &ss) b.BoolVar("b2", "bool2 flag", false, &b2) _, err := app.Parse([]string{}) require.NoError(t, err) assert.Empty(t, ss) assert.False(t, b2) } func TestCobraRegexValueSetStringType(t *testing.T) { var r *regexp.Regexp rv := ®expValue{target: &r} require.Equal(t, "regexp", rv.Type()) // empty when target nil assert.Empty(t, rv.String()) // invalid pattern returns error err := rv.Set("(") require.Error(t, err) // valid pattern sets target err = rv.Set("^foo$") require.NoError(t, err) require.NotNil(t, r) assert.Equal(t, "^foo$", r.String()) assert.Equal(t, "^foo$", rv.String()) } func TestKingpinRegexpVarDefaultAndParse(t *testing.T) { app := kingpin.New("test", "") b := NewKingpinBinder(app) var r *regexp.Regexp b.RegexpVar("re", "help", regexp.MustCompile("^a+$"), &r) _, err := app.Parse([]string{}) require.NoError(t, err) require.NotNil(t, r) assert.Equal(t, "^a+$", r.String()) // user-provided value should override default var r2 *regexp.Regexp app2 := kingpin.New("test2", "") b2 := NewKingpinBinder(app2) b2.RegexpVar("re", "help", nil, &r2) _, err = app2.Parse([]string{"--re=^b+$"}) require.NoError(t, err) require.NotNil(t, r2) assert.Equal(t, "^b+$", r2.String()) } func TestKingpinStringsEnumVarWithAndWithoutDefault(t *testing.T) { app := kingpin.New("test", "") b := NewKingpinBinder(app) var vals []string b.StringsEnumVar("se", "help", []string{"a", "b"}, &vals, "a", "b", "c") _, err := app.Parse([]string{}) require.NoError(t, err) assert.ElementsMatch(t, []string{"a", "b"}, vals) // without default app2 := kingpin.New("test2", "") b2 := NewKingpinBinder(app2) var vals2 []string b2.StringsEnumVar("se", "help", nil, &vals2, "a", "b", "c") _, err = app2.Parse([]string{"--se=a", "--se=c"}) require.NoError(t, err) assert.ElementsMatch(t, []string{"a", "c"}, vals2) } func TestSetRegexDefaultPanicsOnInvalidDefault(t *testing.T) { bs := &badSetter{} def := regexp.MustCompile("^") require.Panics(t, func() { setRegexpDefault(bs, def, "flag") }) } ================================================ FILE: internal/gen/docs/flags/main.go ================================================ /* 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 main import ( "embed" "fmt" "os" "strings" "sigs.k8s.io/external-dns/internal/gen/docs/render" cfg "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) var ( //go:embed "templates/*" templates embed.FS ) type Flag struct { Name string Description string } type Flags []Flag // addFlag adds a new flag to the Flags slice. func (f *Flags) addFlag(name, description string) { *f = append(*f, Flag{Name: name, Description: description}) } // main generates a markdown file with the supported flags // and writes it to the 'docs/flags.md' file. // To re-generate, execute 'go run internal/gen/docs/flags/main.go'. func main() { testPath, _ := os.Getwd() path := fmt.Sprintf("%s/docs/flags.md", testPath) fmt.Printf("generate file '%s' with supported flags\n", path) flags := computeFlags() content, err := flags.generateMarkdownTable() if err != nil { _ = fmt.Errorf("failed to generate markdown file '%s': %v", path, err.Error()) } content += "\n" _ = render.WriteToFile(path, content) } func computeFlags() Flags { app := cfg.App(&cfg.Config{}) modelFlags := app.Model().Flags flags := Flags{} for _, flag := range modelFlags { // do not include helpers and completion flags if strings.Contains(flag.Name, "help") || strings.Contains(flag.Name, "completion-") { continue } flagString := "" flagName := flag.Name if flag.IsBoolFlag() { flagName = "[no-]" + flagName } flagString += fmt.Sprintf("--%s", flagName) if !flag.IsBoolFlag() { flagString += fmt.Sprintf("=%s", flag.FormatPlaceHolder()) } flags.addFlag(fmt.Sprintf("`%s`", flagString), flag.HelpWithEnvar()) } return flags } type columnWidths struct { Flag int Description int } func computeFlagColumnWidths(flags Flags) columnWidths { return columnWidths{ Flag: render.MapColumn("Flag", flags, func(f Flag) string { return f.Name }), Description: render.MapColumn("Description", flags, func(f Flag) string { return f.Description }), } } type templateData struct { Flags Flags ColWidths columnWidths } func (f *Flags) generateMarkdownTable() (string, error) { return render.RenderTemplate(templates, "flags.gotpl", templateData{ Flags: *f, ColWidths: computeFlagColumnWidths(*f), }) } ================================================ FILE: internal/gen/docs/flags/main_test.go ================================================ /* 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 main import ( "fmt" "io/fs" "os" "strings" "testing" "github.com/stretchr/testify/assert" ) const pathToDocs = "%s/../../../../docs" func TestComputeFlags(t *testing.T) { flags := computeFlags() if len(flags) == 0 { t.Errorf("Expected non-zero flags, got %d", len(flags)) } for _, flag := range flags { if strings.Contains(flag.Name, "help") || strings.Contains(flag.Name, "completion-") { t.Errorf("Unexpected flag: %s", flag.Name) } } } func TestGenerateMarkdownTableRenderer(t *testing.T) { flags := Flags{ {Name: "flag1", Description: "description1"}, } got, err := flags.generateMarkdownTable() assert.NoError(t, err) assert.Contains(t, got, "") assert.Contains(t, got, "| flag1 | description1 |") } func TestFlagsMdExists(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) fileName := "flags.md" st, err := fs.Stat(fsys, fileName) assert.NoError(t, err, "expected file %s to exist", fileName) assert.Equal(t, fileName, st.Name()) } func TestFlagsMdUpToDate(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) fileName := "flags.md" expected, err := fs.ReadFile(fsys, fileName) assert.NoError(t, err, "expected file %s to exist", fileName) flags := computeFlags() actual, err := flags.generateMarkdownTable() assert.NoError(t, err) actual += "\n" assert.Equal(t, string(expected), actual, "expected file '%s' to be up to date. execute 'make generate-flags-documentation'", fileName) } func TestFlagsMdExtraFlagAdded(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) filePath := "flags.md" expected, err := fs.ReadFile(fsys, filePath) assert.NoError(t, err, "expected file %s to exist", filePath) flags := computeFlags() flags.addFlag("new-flag", "description2") actual, err := flags.generateMarkdownTable() assert.NoError(t, err) assert.NotEqual(t, string(expected), actual) } ================================================ FILE: internal/gen/docs/flags/templates/flags.gotpl ================================================ --- tags: - flags - autogenerated --- # Flags | {{ padRight .ColWidths.Flag "Flag" }} | {{ padRight .ColWidths.Description "Description" }} | |:{{ leftSep .ColWidths.Flag }}|:{{ leftSep .ColWidths.Description }}| {{- range .Flags }} | {{ padRight $.ColWidths.Flag .Name }} | {{ padRight $.ColWidths.Description .Description }} | {{- end -}} ================================================ FILE: internal/gen/docs/metrics/main.go ================================================ /* 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 main import ( "embed" "fmt" "os" "slices" "sort" "strings" "github.com/prometheus/client_golang/prometheus" "sigs.k8s.io/external-dns/internal/gen/docs/render" "sigs.k8s.io/external-dns/pkg/metrics" // these imports are necessary for the code generation process. _ "sigs.k8s.io/external-dns/controller" _ "sigs.k8s.io/external-dns/provider" _ "sigs.k8s.io/external-dns/provider/webhook" ) var ( //go:embed "templates/*" templates embed.FS ) func main() { testPath, _ := os.Getwd() path := fmt.Sprintf("%s/docs/monitoring/metrics.md", testPath) fmt.Printf("generate file '%s' with configured metrics\n", path) content, err := generateMarkdownTable(metrics.RegisterMetric, true) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to generate markdown file '%s': %v\n", path, err) os.Exit(1) } content += "\n" _ = render.WriteToFile(path, content) } func generateMarkdownTable(m *metrics.MetricRegistry, withRuntime bool) (string, error) { sortMetrics(m.Metrics) var runtimeMetrics []string if withRuntime { runtimeMetrics = getRuntimeMetrics(prometheus.DefaultGatherer) // available when promhttp.Handler() is activated runtimeMetrics = append(runtimeMetrics, []string{ "process_network_receive_bytes_total", "process_network_transmit_bytes_total", }...) sort.Strings(runtimeMetrics) runtimeMetrics = slices.Compact(runtimeMetrics) } else { runtimeMetrics = []string{} } return render.RenderTemplate(templates, "metrics.gotpl", templateData{ Metrics: m.Metrics, RuntimeMetrics: runtimeMetrics, ColWidths: computeColumnWidths(m.Metrics), RuntimeWidth: render.ComputeColumnWidth("Name", runtimeMetrics), }) } // sortMetrics sorts the given slice of metrics by their subsystem and name. // Metrics are first sorted by their subsystem, and then by their name within each subsystem. func sortMetrics(metrics []*metrics.Metric) { sort.Slice(metrics, func(i, j int) bool { if metrics[i].Subsystem == metrics[j].Subsystem { return metrics[i].Name < metrics[j].Name } return metrics[i].Subsystem < metrics[j].Subsystem }) } // getRuntimeMetrics retrieves the list of runtime metrics from the Prometheus registry. func getRuntimeMetrics(gatherer prometheus.Gatherer) []string { mfs, err := gatherer.Gather() if err != nil { return nil } runtimeMetrics := make([]string, 0, len(mfs)) for _, mf := range mfs { name := mf.GetName() if !strings.HasPrefix(name, "external_dns") { runtimeMetrics = append(runtimeMetrics, name) } } return runtimeMetrics } type templateData struct { Metrics []*metrics.Metric RuntimeMetrics []string ColWidths columnWidths RuntimeWidth int } type columnWidths struct { Name int Type int Subsystem int Help int } func computeColumnWidths(ms []*metrics.Metric) columnWidths { return columnWidths{ Name: render.MapColumn("Name", ms, func(m *metrics.Metric) string { return m.Name }), Type: render.MapColumn("Metric Type", ms, func(m *metrics.Metric) string { return m.Type }), Subsystem: render.MapColumn("Subsystem", ms, func(m *metrics.Metric) string { return m.Subsystem }), Help: render.MapColumn("Help", ms, func(m *metrics.Metric) string { return m.Help }), } } ================================================ FILE: internal/gen/docs/metrics/main_test.go ================================================ /* 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 main import ( "fmt" "io/fs" "os" "testing" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/pkg/metrics" ) const ( pathToDocs = "%s/../../../../docs/monitoring" knownMetricsCount = 22 ) func TestComputeMetrics(t *testing.T) { reg := metrics.RegisterMetric if len(reg.Metrics) == 0 { t.Errorf("Expected not empty metrics registry, got %d", len(reg.Metrics)) } assert.Len(t, reg.Metrics, knownMetricsCount) } func TestGenerateMarkdownTableRenderer(t *testing.T) { reg := metrics.NewMetricsRegister() got, err := generateMarkdownTable(reg, false) assert.NoError(t, err) assert.Contains(t, got, "# Available Metrics\n\n\n") assert.Contains(t, got, "| Name | Metric Type | Subsystem | Help |") } func TestGenerateMarkdownTableWithSingleMetric(t *testing.T) { reg := metrics.NewMetricsRegister() reg.MustRegister(metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Namespace: "external_dns", Subsystem: "controller_0", Name: "verified_aaaa_records", Help: "This is just a test.", }, )) got, err := generateMarkdownTable(reg, false) require.NoError(t, err) assert.Contains(t, got, "verified_aaaa_records") assert.Contains(t, got, "This is just a test.") } func TestMetricsMdUpToDate(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) fileName := "metrics.md" expected, err := fs.ReadFile(fsys, fileName) assert.NoError(t, err, "expected file %s to exist", fileName) reg := metrics.RegisterMetric actual, err := generateMarkdownTable(reg, false) assert.NoError(t, err) assert.Contains(t, string(expected), actual, "expected file 'docs/monitoring/metrics.md' to be up to date. execute 'make generate-metrics-documentation") } func TestMetricsMdExtraMetricAdded(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) fileName := "metrics.md" expected, err := fs.ReadFile(fsys, fileName) assert.NoError(t, err, "expected file %s to exist", fileName) // Use a fresh registry to avoid mutating the global RegisterMetric. reg := metrics.NewMetricsRegister() for _, m := range metrics.RegisterMetric.Metrics { reg.Metrics = append(reg.Metrics, m) } reg.MustRegister(metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Namespace: "external_dns", Subsystem: "controller_1", Name: "verified_aaaa_records", Help: "This is just a test.", }, )) actual, err := generateMarkdownTable(reg, false) assert.NoError(t, err) assert.NotEqual(t, string(expected), actual) } func TestGetRuntimeMetricsForNewRegistry(t *testing.T) { reg := prometheus.NewRegistry() // Register some runtime metrics reg.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{ Name: "go_goroutines", Help: "Number of goroutines that currently exist.", })) reg.MustRegister(prometheus.NewGauge(prometheus.GaugeOpts{ Name: "go_memstats_alloc_bytes", Help: "Number of bytes allocated and still in use.", })) runtimeMetrics := getRuntimeMetrics(reg) // Check that the runtime metrics are correctly retrieved expectedMetrics := []string{"go_goroutines", "go_memstats_alloc_bytes"} assert.ElementsMatch(t, expectedMetrics, runtimeMetrics) assert.Len(t, runtimeMetrics, 2) } func TestGetRuntimeMetricsForDefaultRegistry(t *testing.T) { runtimeMetrics := getRuntimeMetrics(prometheus.DefaultGatherer) if len(runtimeMetrics) == 0 { t.Errorf("Expected not empty runtime metrics, got %d", len(runtimeMetrics)) } } ================================================ FILE: internal/gen/docs/metrics/templates/metrics.gotpl ================================================ --- tags: - metrics - autogenerated --- # Available Metrics All metrics available for scraping are exposed on the {{backtick 1}}/metrics{{backtick 1}} endpoint. The metrics are in the Prometheus exposition format. To access the metrics: {{backtick 3}}sh curl https://localhost:7979/metrics {{backtick 3}} ## Supported Metrics > Full metric name is constructed as follows: > {{backtick 1}}external_dns__{{backtick 1}} | {{ padRight .ColWidths.Name "Name" }} | {{ padRight .ColWidths.Type "Metric Type" }} | {{ padRight .ColWidths.Subsystem "Subsystem" }} | {{ padRight .ColWidths.Help "Help" }} | |:{{ leftSep .ColWidths.Name }}|:{{ leftSep .ColWidths.Type }}|:{{ leftSep .ColWidths.Subsystem }}|:{{ leftSep .ColWidths.Help }}| {{- range .Metrics }} | {{ padRight $.ColWidths.Name .Name }} | {{ padRight $.ColWidths.Type (.Type | capitalize) }} | {{ padRight $.ColWidths.Subsystem .Subsystem }} | {{ padRight $.ColWidths.Help .Help }} | {{- end }} ## Available Go Runtime Metrics > The following Go runtime metrics are available for scraping. Please note that they may change over time and they are OS dependent. {{ if .RuntimeMetrics -}} | {{ padRight .RuntimeWidth "Name" }} | |:{{ leftSep .RuntimeWidth }}| {{- range .RuntimeMetrics }} | {{ padRight $.RuntimeWidth . }} | {{- end -}} {{- end -}} ================================================ FILE: internal/gen/docs/render/render.go ================================================ /* 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 render import ( "bytes" "fmt" "io/fs" "os" "strings" "text/template" "golang.org/x/text/cases" "golang.org/x/text/language" ) // WriteToFile writes the given content to a file, creating or truncating it. func WriteToFile(filename string, content string) error { return os.WriteFile(filename, []byte(content), 0644) } // RenderTemplate parses and executes a named Go template from the given filesystem. func RenderTemplate(fsys fs.FS, name string, data any) (string, error) { tmpl := template.New("").Funcs(FuncMap()) template.Must(tmpl.ParseFS(fsys, "templates/*.gotpl")) var b bytes.Buffer if err := tmpl.ExecuteTemplate(&b, name, data); err != nil { return "", err } return b.String(), nil } // FuncMap returns a mapping of all of the functions that Engine has. func FuncMap() template.FuncMap { return template.FuncMap{ "backtick": func(times int) string { return strings.Repeat("`", times) }, "capitalize": cases.Title(language.English, cases.Compact).String, "replace": strings.ReplaceAll, "lower": strings.ToLower, "bold": func(s string) string { return "**" + s + "**" }, // padRight pads s with spaces on the right to the given width. "padRight": func(width int, s string) string { return fmt.Sprintf("%-*s", width, s) }, // leftSep generates a left-aligned markdown table separator of the given column width. "leftSep": func(width int) string { return strings.Repeat("-", width+1) }, } } // ComputeColumnWidth returns the maximum string length among the header and all values. func ComputeColumnWidth(header string, values []string) int { return MapColumn(header, values, func(s string) string { return s }) } // MapColumn returns the max width among the header and fn applied to each item. func MapColumn[T any](header string, items []T, fn func(T) string) int { w := len(header) for _, item := range items { w = max(w, len(fn(item))) } return w } ================================================ FILE: internal/gen/docs/render/render_test.go ================================================ /* 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 render import ( "fmt" "os" "strings" "testing" "testing/fstest" "text/template" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWriteToFile(t *testing.T) { filename := fmt.Sprintf("%s/testfile", t.TempDir()) content := "Hello, World!" defer os.Remove(filename) err := WriteToFile(filename, content) if err != nil { t.Fatalf("expected no error, got %v", err) } data, err := os.ReadFile(filename) if err != nil { t.Fatalf("expected no error reading file, got %v", err) } if string(data) != content { t.Errorf("expected content %q, got %q", content, string(data)) } } func TestComputeColumnWidth(t *testing.T) { tests := []struct { name string header string values []string want int }{ { name: "header wins when all values are shorter", header: "Metric Type", values: []string{"gauge", "counter"}, want: len("Metric Type"), }, { name: "value wins when longer than header", header: "Name", values: []string{"last_reconcile_timestamp_seconds"}, want: len("last_reconcile_timestamp_seconds"), }, { name: "empty values returns header length", header: "Subsystem", values: []string{}, want: len("Subsystem"), }, { name: "empty header and empty values returns zero", header: "", values: []string{}, want: 0, }, { name: "empty header defers to longest value", header: "", values: []string{"short", "much longer value"}, want: len("much longer value"), }, { name: "empty string values do not shrink below header", header: "Help", values: []string{"", ""}, want: len("Help"), }, { name: "tie between header and value returns that length", header: "exact", values: []string{"exact"}, want: len("exact"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ComputeColumnWidth(tt.header, tt.values) assert.Equal(t, tt.want, got) }) } } func TestMapColumn(t *testing.T) { type item struct{ val string } fn := func(i item) string { return i.val } tests := []struct { name string header string items []item want int }{ { name: "header wins when all values are shorter", header: "Metric Type", items: []item{{"gauge"}, {"counter"}}, want: len("Metric Type"), }, { name: "value wins when longer than header", header: "Name", items: []item{{"last_reconcile_timestamp_seconds"}}, want: len("last_reconcile_timestamp_seconds"), }, { name: "empty items returns header length", header: "Subsystem", items: []item{}, want: len("Subsystem"), }, { name: "empty header and empty items returns zero", header: "", items: []item{}, want: 0, }, { name: "empty header defers to longest value", header: "", items: []item{{"short"}, {"much longer value"}}, want: len("much longer value"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := MapColumn(tt.header, tt.items, fn) assert.Equal(t, tt.want, got) }) } } func TestFuncs(t *testing.T) { tests := []struct { tpl, expect string vars any }{ { tpl: `{{ backtick 3 }}`, expect: "```", vars: map[string]any{}, }, { tpl: `{{ capitalize .name }}`, expect: "Capital", vars: map[string]any{"name": "capital"}, }, { tpl: `{{ replace .resources "," "
" }}`, expect: "one
two
tree", vars: map[string]any{"resources": "one,two,tree"}, }, } for _, tt := range tests { var b strings.Builder err := template.Must(template.New("test").Funcs(FuncMap()).Parse(tt.tpl)).Execute(&b, tt.vars) assert.NoError(t, err) assert.Equal(t, tt.expect, b.String(), tt.tpl) } } func TestRenderTemplate(t *testing.T) { fsys := fstest.MapFS{ "templates/test.gotpl": &fstest.MapFile{ Data: []byte("Hello {{ .Name }}!"), }, } result, err := RenderTemplate(fsys, "test.gotpl", struct{ Name string }{Name: "World"}) require.NoError(t, err) assert.Equal(t, "Hello World!", result) } func TestRenderTemplateWithFuncMap(t *testing.T) { fsys := fstest.MapFS{ "templates/test.gotpl": &fstest.MapFile{ Data: []byte("{{ backtick 3 }}go\nfmt.Println({{ capitalize .Lang }})\n{{ backtick 3 }}"), }, } result, err := RenderTemplate(fsys, "test.gotpl", struct{ Lang string }{Lang: "go"}) require.NoError(t, err) assert.Contains(t, result, "```go") assert.Contains(t, result, "Go") } ================================================ FILE: internal/gen/docs/sources/main.go ================================================ /* 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 main import ( "embed" "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "regexp" "slices" "strings" "sigs.k8s.io/external-dns/internal/gen/docs/render" ) const ( annotationPrefix = "+externaldns:source:" annotationName = annotationPrefix + "name=" annotationCategory = annotationPrefix + "category=" annotationDesc = annotationPrefix + "description=" annotationResources = annotationPrefix + "resources=" annotationFilters = annotationPrefix + "filters=" annotationNamespace = annotationPrefix + "namespace=" annotationFQDNTemplate = annotationPrefix + "fqdn-template=" annotationEvents = annotationPrefix + "events=" annotationProviderSpecific = annotationPrefix + "provider-specific=" ) var ( //go:embed "templates/*" templates embed.FS // Regex to match source type names (must end with "Source") sourceTypeRegex = regexp.MustCompile(`^(\w+)Source$`) ) // Source represents metadata about a source implementation type Source struct { Name string // e.g., "service", "ingress", "crd" Type string // e.g., "serviceSource" File string // e.g., "source/service.go" Description string // Description of what this source does Category string // e.g., "Kubernetes", "Gateway", "Service Mesh", "Wrapper" Resources string // Kubernetes resources watched, e.g., "Service", "Ingress" Filters string // Supported filters, e.g., "annotation,label" Namespace string // Namespace support: "all", "single", "multiple" FQDNTemplate string // FQDN template support: "true", "false" Events string // Events support: "true", "false" ProviderSpecific string // Provider-specific properties support: "true", "false" } type Sources []Source // main generates a markdown file with the supported sources // and writes it to the 'docs/sources/index.md' file. // To re-generate, execute 'go run internal/gen/docs/sources/main.go'. func main() { cPath, _ := os.Getwd() path := fmt.Sprintf("%s/docs/sources/index.md", cPath) fmt.Printf("generate file '%s' with supported sources\n", path) sources, err := discoverSources(fmt.Sprintf("%s/source", cPath)) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to discover sources: %v\n", err) os.Exit(1) } content, err := sources.generateMarkdown() if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to generate markdown file '%s': %v\n", path, err) os.Exit(1) } _ = render.WriteToFile(path, content) } // discoverSources scans the source directory and discovers all source implementations // by parsing Go files and extracting +externaldns:source annotations func discoverSources(dir string) (Sources, error) { // Parse all source files for annotations sources, err := parseSourceAnnotations(dir) if err != nil { return nil, err } // Sort sources by name slices.SortFunc(sources, func(a, b Source) int { return strings.Compare(a.Name, b.Name) }) return sources, nil } type columnWidths struct { Name int Resources int Filters int Namespace int FQDNTemplate int Events int ProviderSpecific int Category int } func computeColumnWidths(sources Sources) columnWidths { return columnWidths{ Name: render.MapColumn("**Source Name**", sources, func(s Source) string { return "**" + s.Name + "**" }), Resources: render.MapColumn("Resources", sources, func(s Source) string { return strings.ReplaceAll(s.Resources, ",", "
") }), Filters: render.MapColumn("Filters", sources, func(s Source) string { return s.Filters }), Namespace: render.MapColumn("Namespace", sources, func(s Source) string { return s.Namespace }), FQDNTemplate: render.MapColumn("FQDN Template", sources, func(s Source) string { return s.FQDNTemplate }), Events: render.MapColumn("Events", sources, func(s Source) string { return s.Events }), ProviderSpecific: render.MapColumn("Provider Specific", sources, func(s Source) string { return s.ProviderSpecific }), Category: render.MapColumn("Category", sources, func(s Source) string { return strings.ToLower(s.Category) }), } } type templateData struct { Sources Sources ColWidths columnWidths } func (s *Sources) generateMarkdown() (string, error) { return render.RenderTemplate(templates, "sources.gotpl", templateData{ Sources: *s, ColWidths: computeColumnWidths(*s), }) } // parseSourceAnnotations parses all Go files in the source directory // and extracts source metadata from +externaldns:source annotations func parseSourceAnnotations(sourceDir string) (Sources, error) { var sources Sources // Walk through the source directory err := filepath.WalkDir(sourceDir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } // Skip directories and non-Go files if d.IsDir() || !strings.HasSuffix(path, ".go") { return nil } // Skip test files if strings.HasSuffix(path, "_test.go") { return nil } // Parse the Go file fileSources, err := parseFile(path, sourceDir) if err != nil { return fmt.Errorf("failed to parse %s: %w", path, err) } sources = append(sources, fileSources...) return nil }) if err != nil { return nil, err } return sources, nil } // parseFile parses a single Go file and extracts source annotations func parseFile(filePath, baseDir string) (Sources, error) { var sources Sources fset := token.NewFileSet() node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments) if err != nil { return nil, err } // Get relative path for the File field relPath, err := filepath.Rel(baseDir, filePath) if err != nil { relPath = filePath } // Normalize to use forward slashes relPath = filepath.ToSlash(relPath) // Create a map of all comments by their starting position cmap := ast.NewCommentMap(fset, node, node.Comments) var errFound error // Inspect the AST for type declarations ast.Inspect(node, func(n ast.Node) bool { // Look for type declarations that are GenDecl (general declarations) genDecl, ok := n.(*ast.GenDecl) if !ok { return true } // Get comments associated with this declaration comments := cmap[genDecl] if len(comments) == 0 { return true } // Check each spec in the declaration for _, spec := range genDecl.Specs { typeSpec, ok := spec.(*ast.TypeSpec) if !ok { continue } // Check if it's a struct type _, ok = typeSpec.Type.(*ast.StructType) if !ok { continue } // Check if the type name matches *Source pattern typeName := typeSpec.Name.Name if !sourceTypeRegex.MatchString(typeName) { continue } // Combine all comment text var commentText strings.Builder for _, cg := range comments { commentText.WriteString(cg.Text()) } if commentText.Len() == 0 { continue } extractedSources, err := extractSourcesFromComments(commentText.String(), typeName, relPath) if err != nil { errFound = err return false } sources = append(sources, extractedSources...) } return true }) return sources, errFound } // extractSourcesFromComments extracts source metadata from comment text. // It can extract multiple sources from the same comment block (e.g., for gateway routes). func extractSourcesFromComments(comments, typeName, filePath string) (Sources, error) { var sources Sources var currentSource *Source for line := range strings.SplitSeq(comments, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, annotationPrefix) { continue } // When we see a name annotation, start a new source switch { case strings.HasPrefix(line, annotationName): // Save previous source if it exists if currentSource != nil && currentSource.Name != "" { sources = append(sources, *currentSource) } // Start new source currentSource = &Source{ Type: typeName, File: filePath, Name: strings.TrimPrefix(line, annotationName), Events: "false", ProviderSpecific: "false", } case currentSource == nil: return nil, fmt.Errorf("found annotation line without preceding source name in type %s: %s", typeName, line) case strings.HasPrefix(line, annotationCategory): currentSource.Category = strings.TrimPrefix(line, annotationCategory) case strings.HasPrefix(line, annotationDesc): currentSource.Description = strings.TrimPrefix(line, annotationDesc) case strings.HasPrefix(line, annotationResources): currentSource.Resources = strings.TrimPrefix(line, annotationResources) case strings.HasPrefix(line, annotationFilters): currentSource.Filters = strings.TrimPrefix(line, annotationFilters) case strings.HasPrefix(line, annotationNamespace): currentSource.Namespace = strings.TrimPrefix(line, annotationNamespace) case strings.HasPrefix(line, annotationFQDNTemplate): currentSource.FQDNTemplate = strings.TrimPrefix(line, annotationFQDNTemplate) case strings.HasPrefix(line, annotationEvents): currentSource.Events = strings.TrimPrefix(line, annotationEvents) case strings.HasPrefix(line, annotationProviderSpecific): currentSource.ProviderSpecific = strings.TrimPrefix(line, annotationProviderSpecific) } } // Don't forget the last source if currentSource != nil && currentSource.Name != "" { sources = append(sources, *currentSource) } return sources, nil } ================================================ FILE: internal/gen/docs/sources/main_test.go ================================================ /* 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 main import ( "fmt" "io/fs" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) const ( pathToDocs = "%s/../../../../docs/sources" fileName = "index.md" ) func TestIndexMdExists(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) st, err := fs.Stat(fsys, fileName) assert.NoError(t, err, "expected file %s to exist", fileName) assert.Equal(t, fileName, st.Name()) } func TestIndexMdUpToDate(t *testing.T) { testPath, _ := os.Getwd() fsys := os.DirFS(fmt.Sprintf(pathToDocs, testPath)) expected, err := fs.ReadFile(fsys, fileName) assert.NoError(t, err, "expected file %s to exist", fileName) sourceDir := fmt.Sprintf("%s/../../../../source", testPath) sources, err := discoverSources(sourceDir) require.NoError(t, err, "expected to find sources") actual, err := sources.generateMarkdown() assert.NoError(t, err) assert.Contains(t, string(expected), actual, "expected file 'docs/sources/index.md' to be up to date. execute 'make generate-sources-documentation'") } func TestDiscoverSources(t *testing.T) { testPath, _ := os.Getwd() sourceDir := fmt.Sprintf("%s/../../../../source", testPath) sources, err := discoverSources(sourceDir) require.NoError(t, err) assert.GreaterOrEqual(t, len(sources), 5, "Expected at least 5 sources with annotations") // Verify sources are sorted by name for i := range len(sources) - 1 { prev, curr := sources[i], sources[i+1] if prev.Name > curr.Name { t.Errorf("Sources not sorted correctly: %s should come before %s", curr.Name, prev.Name) } } } func TestGenerateMarkdown(t *testing.T) { sources := Sources{ { Name: "test", Type: "testSource", File: "source/test.go", Category: "Test", Description: "Test source", Resources: "TestResource", Filters: "annotation,label", Namespace: "all,single", FQDNTemplate: "true", }, } content, err := sources.generateMarkdown() require.NoError(t, err) assert.NotEmpty(t, content) assert.Contains(t, content, "# Supported Sources") assert.Contains(t, content, "## Available Sources") } func TestParseSourceAnnotations(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "test_source.go") content := `package main // testSource is a test source implementation. // // +externaldns:source:name=test-source // +externaldns:source:category=Testing // +externaldns:source:description=A test source for unit testing // +externaldns:source:resources=TestResource // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true type testSource struct { client string } ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseSourceAnnotations(tmpDir) require.NoError(t, err) assert.Len(t, sources, 1) source := sources[0] assert.Equal(t, "test-source", source.Name) assert.Equal(t, "Testing", source.Category) assert.Equal(t, "TestResource", source.Resources) assert.Equal(t, "annotation,label", source.Filters) assert.Equal(t, "all,single", source.Namespace) assert.Equal(t, "true", source.FQDNTemplate) assert.Equal(t, "false", source.Events) assert.Equal(t, "true", source.ProviderSpecific) } func TestParseSourceAnnotations_SkipsTestFiles(t *testing.T) { tmpDir := t.TempDir() // Create a test file that should be skipped testFile := filepath.Join(tmpDir, "test_source_test.go") content := `package main // +externaldns:source:name=should-be-skipped // +externaldns:source:category=Test // +externaldns:source:description=Should be skipped type testSource struct {} ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseSourceAnnotations(tmpDir) require.NoError(t, err) assert.Empty(t, sources) } func TestParseFile_MultipleSourcesInOneFile(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "multi.go") content := `package main // firstSource is the first source. // // +externaldns:source:name=first // +externaldns:source:category=Testing // +externaldns:source:description=First source type firstSource struct {} // secondSource is the second source. // // +externaldns:source:name=second // +externaldns:source:category=Testing // +externaldns:source:description=Second source // +externaldns:source:events=true type secondSource struct {} ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseFile(testFile, tmpDir) require.NoError(t, err) assert.Len(t, sources, 2) assert.Equal(t, "first", sources[0].Name) assert.Equal(t, "false", sources[0].Events) assert.Equal(t, "false", sources[0].ProviderSpecific) assert.Equal(t, "second", sources[1].Name) assert.Equal(t, "true", sources[1].Events) assert.Equal(t, "false", sources[1].ProviderSpecific) } func TestParseFile_IgnoresNonSourceTypes(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "nonsource.go") content := `package main // regularStruct is not a source (doesn't end with "Source"). // // +externaldns:source:name=should-not-parse // +externaldns:source:category=Test // +externaldns:source:description=Should not be parsed type regularStruct struct {} ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseFile(testFile, tmpDir) require.NoError(t, err) assert.Empty(t, sources) } func TestParseSourceAnnotations_ErrorOnInvalidFile(t *testing.T) { tmpDir := t.TempDir() // Create a file with invalid Go syntax testFile := filepath.Join(tmpDir, "invalid.go") content := `package main this is not valid go syntax ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) _, err = parseSourceAnnotations(tmpDir) require.Error(t, err) } func TestParseFile_InvalidGoFile(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "invalid.go") content := `this is not valid go code` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) _, err = parseFile(testFile, tmpDir) require.Error(t, err) } func TestParseSourceAnnotations_WithSubdirectories(t *testing.T) { tmpDir := t.TempDir() subDir := filepath.Join(tmpDir, "subdir") if err := os.Mkdir(subDir, 0755); err != nil { t.Fatalf("Failed to create subdirectory: %v", err) } // Create a test file in subdirectory testFile := filepath.Join(subDir, "nested_source.go") content := `package main // nestedSource is in a subdirectory. // // +externaldns:source:name=nested // +externaldns:source:category=Testing // +externaldns:source:description=Nested source type nestedSource struct {} ` err := os.WriteFile(testFile, []byte(content), 0644) require.NoError(t, err) sources, err := parseSourceAnnotations(tmpDir) require.NoError(t, err) assert.Len(t, sources, 1) assert.Equal(t, "nested", sources[0].Name) assert.Contains(t, sources[0].File, "subdir/nested_source.go") } func TestGenerateMarkdown_WithMultipleCategories(t *testing.T) { sources := Sources{ { Name: "service", Category: "Kubernetes Core", Description: "Service source", Resources: "Service", Filters: "annotation,label", Namespace: "all,single", FQDNTemplate: "true", }, { Name: "ingress", Category: "Kubernetes Core", Description: "Ingress source", Resources: "Ingress", Filters: "annotation,label", Namespace: "all,single", FQDNTemplate: "true", }, { Name: "gateway-httproute", Category: "Gateway API", Description: "HTTP route source", Resources: "HTTPRoute.gateway.networking.k8s.io", Filters: "annotation,label", Namespace: "all,single", FQDNTemplate: "false", }, } content, err := sources.generateMarkdown() require.NoError(t, err) assert.Contains(t, content, "service") assert.Contains(t, content, "ingress") assert.Contains(t, content, "gateway-httproute") } func TestExtractSourcesFromComments(t *testing.T) { tests := []struct { name string comments string typeName string filePath string wantSources int wantErr bool validate func(*testing.T, Source) }{ { name: "valid single source", comments: `testSource is a test implementation. +externaldns:source:name=test +externaldns:source:category=Testing +externaldns:source:description=A test source +externaldns:source:resources=TestResource +externaldns:source:filters=annotation +externaldns:source:namespace=all +externaldns:source:fqdn-template=false `, typeName: "testSource", filePath: "test.go", wantSources: 1, validate: func(t *testing.T, s Source) { assert.Equal(t, "test", s.Name) assert.Equal(t, "Testing", s.Category) assert.Equal(t, "A test source", s.Description) }, }, { name: "multiple sources in same comment block", comments: `gatewaySource handles multiple gateway types. +externaldns:source:name=http-route +externaldns:source:category=Gateway +externaldns:source:description=Handles HTTP routes +externaldns:source:resources=HTTPRoute +externaldns:source:name=tcp-route +externaldns:source:category=Gateway +externaldns:source:description=Handles TCP routes +externaldns:source:resources=TCPRoute `, typeName: "gatewaySource", filePath: "gateway.go", wantSources: 2, validate: func(t *testing.T, s Source) { assert.Contains(t, []string{"http-route", "tcp-route"}, s.Name) }, }, { name: "missing required name annotation", comments: `testSource without name. +externaldns:source:category=Testing +externaldns:source:description=Missing name `, typeName: "testSource", filePath: "test.go", wantErr: true, }, { name: "optional annotations can be missing", comments: `testSource with minimal annotations. +externaldns:source:name=minimal +externaldns:source:category=Testing +externaldns:source:description=Minimal source `, typeName: "testSource", filePath: "test.go", wantSources: 1, validate: func(t *testing.T, s Source) { assert.Equal(t, "minimal", s.Name) assert.Empty(t, s.Resources) assert.Empty(t, s.Filters) }, }, { name: "empty name annotation", wantSources: 0, comments: `testSource with minimal annotations. +externaldns:source:name= +externaldns:source:category=Testing +externaldns:source:description=Minimal source `, typeName: "testSource", filePath: "test.go", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sources, err := extractSourcesFromComments(tt.comments, tt.typeName, tt.filePath) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Len(t, sources, tt.wantSources) if tt.validate != nil { for _, source := range sources { tt.validate(t, source) } } // Verify all sources have required fields for _, source := range sources { assert.Equal(t, tt.typeName, source.Type) assert.Equal(t, tt.filePath, source.File) } }) } } ================================================ FILE: internal/gen/docs/sources/templates/sources.gotpl ================================================ --- tags: - sources - autogenerated --- # Supported Sources ExternalDNS supports multiple sources for discovering DNS records. Each source watches specific Kubernetes or cloud platform resources and generates DNS records based on their configuration. ## Overview Sources are responsible for: - Watching Kubernetes resources or external APIs - Extracting DNS information from annotations and resource specifications - Generating DNS endpoint records for providers to consume ## Available Sources | {{ padRight .ColWidths.Name "**Source Name**" }} | {{ padRight .ColWidths.Filters "Filters" }} | {{ padRight .ColWidths.Namespace "Namespace" }} | {{ padRight .ColWidths.FQDNTemplate "FQDN Template" }} | {{ padRight .ColWidths.Events "Events" }} | {{ padRight .ColWidths.ProviderSpecific "Provider Specific" }} | {{ padRight .ColWidths.Category "Category" }} | {{ padRight .ColWidths.Resources "Resources" }} | |:{{ leftSep .ColWidths.Name }}|:{{ leftSep .ColWidths.Filters }}|:{{ leftSep .ColWidths.Namespace }}|:{{ leftSep .ColWidths.FQDNTemplate }}|:{{ leftSep .ColWidths.Events }}|:{{ leftSep .ColWidths.ProviderSpecific }}|:{{ leftSep .ColWidths.Category }}|:{{ leftSep .ColWidths.Resources }}| {{- range .Sources }} | {{ padRight $.ColWidths.Name (.Name | bold) }} | {{ padRight $.ColWidths.Filters .Filters }} | {{ padRight $.ColWidths.Namespace .Namespace }} | {{ padRight $.ColWidths.FQDNTemplate .FQDNTemplate }} | {{ padRight $.ColWidths.Events .Events }} | {{ padRight $.ColWidths.ProviderSpecific .ProviderSpecific }} | {{ padRight $.ColWidths.Category (lower .Category) }} | {{ padRight $.ColWidths.Resources (replace .Resources "," "
") }} | {{- end }} ## Usage To use a specific source, configure ExternalDNS with the {{backtick 1}}--source{{backtick 1}} flag: {{backtick 3}}bash external-dns --source=service --source=ingress {{backtick 3}} Multiple sources can be combined to watch different resource types simultaneously. ## Source Categories - **Kubernetes Core**: Native Kubernetes resources (Service, Ingress, Pod, Node) - **ExternalDNS**: Native ExternalDNS resources - **Gateway API**: Kubernetes Gateway API resources (Gateway, HTTPRoute, etc.) - **Service Mesh**: Service mesh implementations (Istio, Gloo) - **Ingress Controllers**: Third-party ingress controller resources (Contour, Traefik, Ambassador, etc.) - **Load Balancers**: Load balancer specific resources (F5) - **OpenShift**: OpenShift specific resources (Route) - **Cloud Platforms**: Cloud platform integrations (Cloud Foundry) - **Wrappers**: Source wrappers that modify or combine other sources - **Special**: Special purpose sources (connector, empty) - **Testing**: Sources used for testing purposes ================================================ FILE: internal/idna/idna.go ================================================ /* 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 idna import ( "strings" log "github.com/sirupsen/logrus" "golang.org/x/net/idna" ) var ( Profile = idna.New( idna.MapForLookup(), idna.Transitional(true), idna.StrictDomainName(false), ) ) // NormalizeDNSName converts a DNS name to a canonical form, so that we can use string equality // it: removes space, get ASCII version of dnsName complient with Section 5 of RFC 5891, ensures there is a trailing dot func NormalizeDNSName(dnsName string) string { s, err := Profile.ToASCII(strings.TrimSpace(dnsName)) if err != nil { log.Warnf(`Got error while parsing DNSName %s: %v`, dnsName, err) } if !strings.HasSuffix(s, ".") { s += "." } return s } ================================================ FILE: internal/idna/idna_test.go ================================================ /* 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 idna import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestProfileWithDefault(t *testing.T) { tets := []struct { input string expected string }{ { input: "*.GÖPHER.com", expected: "*.göpher.com", }, { input: "*._abrakadabra.com", expected: "*._abrakadabra.com", }, { input: "_abrakadabra.com", expected: "_abrakadabra.com", }, { input: "*.foo.kube.example.com", expected: "*.foo.kube.example.com", }, { input: "xn--bcher-kva.example.com", expected: "bücher.example.com", }, } for _, tt := range tets { t.Run(strings.ToLower(tt.input), func(t *testing.T) { result, err := Profile.ToUnicode(tt.input) assert.NoError(t, err) assert.Equal(t, tt.expected, result) }) } } func TestNormalizeDNSName(tt *testing.T) { records := []struct { dnsName string expect string }{ { "3AAAA.FOO.BAR.COM ", "3aaaa.foo.bar.com.", }, { " example.foo.com.", "example.foo.com.", }, { "example123.foo.com ", "example123.foo.com.", }, { "foo", "foo.", }, { "123foo.bar", "123foo.bar.", }, { "foo.com", "foo.com.", }, { "foo.com.", "foo.com.", }, { "_foo.com.", "_foo.com.", }, { "\u005Ffoo.com.", "_foo.com.", }, { ".foo.com.", ".foo.com.", }, { "foo123.COM", "foo123.com.", }, { "my-exaMple3.FOO.BAR.COM", "my-example3.foo.bar.com.", }, { " my-example1214.FOO-1235.BAR-foo.COM ", "my-example1214.foo-1235.bar-foo.com.", }, { "my-example-my-example-1214.FOO-1235.BAR-foo.COM", "my-example-my-example-1214.foo-1235.bar-foo.com.", }, { "點看.org.", "xn--c1yn36f.org.", }, { "nordic-ø.xn--kitty-點看pd34d.com", "xn--nordic--w1a.xn--xn--kitty-pd34d-hn01b3542b.com.", }, { "nordic-ø.kitty😸.com.", "xn--nordic--w1a.xn--kitty-pd34d.com.", }, { " nordic-ø.kitty😸.COM", "xn--nordic--w1a.xn--kitty-pd34d.com.", }, { "xn--nordic--w1a.kitty😸.com.", "xn--nordic--w1a.xn--kitty-pd34d.com.", }, { "*.example.com.", "*.example.com.", }, { "*.example.com", "*.example.com.", }, } for _, r := range records { tt.Run(r.dnsName, func(t *testing.T) { gotName := NormalizeDNSName(r.dnsName) assert.Equal(t, r.expect, gotName) }) } } ================================================ FILE: internal/testresources/ca.pem ================================================ -----BEGIN CERTIFICATE----- MIIDDzCCAfegAwIBAgIUbIVuK9I6w/4U7WQCF4XYTn5qHUYwDQYJKoZIhvcNAQEL BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MTAxMDE5MDIwNVoXDTM1 MTAwODE5MDIwNVowFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMIIBIjANBgkqhkiG 9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyYQae0ZtDTb1I8+eof1SmMSZ0AQzxC3ZrJqa mNXitNQl345Oqo/Rw2ypqNPJ3kqs66KWFm43QzAkUg8sk31AK7Ddf9qxzoqqME0L pckTDDZoPsZQSVKYfCDeimd7Rc6g/kg7QUHbuNJpxHU+3/WOHPFQAPGDIFzy7hPT Tk1m00Hg+qQThIHkvEzlgYxRL9qVu4+Xxa+PqRck3If4ladMigEJSQBhNVbYpoP1 qfHcS6ppFnCemNXPTvnE+Qkl4d/GFjOCWWJTU6lIpMJQB23rcp5P7y/QahapWonR TBzUGD3RenVydqCj7g36XQygfDKSuXo2EjsafPYSRyKWIDWsNQIDAQABo1MwUTAd BgNVHQ4EFgQUvi2RqnpYDVcyOAe0qPOThztmcjMwHwYDVR0jBBgwFoAUvi2RqnpY DVcyOAe0qPOThztmcjMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC AQEAn2jeh6I9ywo8vsvjtippWfclQbC4/p9Au5PTMnfHTkL6f7OYs6swg0Kltam8 peiIQUI2cKHuVNGyUFqeoaJmydJ+tgyQOmeaC1m1zx++ck8hpLdz0sP4/Psf2u8B afQ5SRfFJfwICeOzrlgSeoMSsPg7IycR6PCiZAGFzdN3qB1lhCQZYpGQH0/Fax1b z0p7kDneaNBQXSsD1igF56R5yzck+QZ4UmvRELEgk0t2JmlLNCrTw9aauGsjhgy/ /amKBlmnywfSogJS+k1GgY5KCtSS9AA6CvqA0qnGeR1S09RiACfCls4Tlo3e/ikL KNJIlBlunVpyIG6OMjLJNZ3FVA== -----END CERTIFICATE----- ================================================ FILE: internal/testresources/client-cert-key.pem ================================================ -----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSqF1QwmVuTf27 UDYzIWw7Fe2k5hC6LuIrusr5zdUToP6b1xrh3O0RF8GOJJoKvJbieptnRxWxWCbH BhZnEznf2NX5Cdn0LbxpD4IE549TNJyBqNtfgRSmTrDlY81xJVOLByFx+b1448fC UFS34qNhtCodGQq8kYfp2pZ4Z9GbwJZJVpNlAL8Ofwnv3mS1b/LShnhhscFuRzrh VV1UVs+9fEWcwSDp+pXn4rV/cMQZTnIck9KYvlq+rkU76+SVpWKEwNFfnw7vvtwm 5nlriKtjk/6kPirAnWBl8JzdhcZcfJ75Wq7prZR4zXB6S57h2UnlJBfNPhMNs5HD Grw2NSUXAgMBAAECggEAB9y8ztzSiFFk3O7bdwESVwo0emkTyr8hNdyc4sHp5/ek SRC4MiHavz6RvMpk7W2oe/9zeWFPz/SoTdlOUL6I9G/VXJwfhFuIoqsvgRtbYBGg nb49oczhhmt9crJM4qIwAgpcFzLo/XAS7o+s+cf9rRHaWIesvOj5l6LO5uOJETUH jUleAR54RpyNTJdYySguabb3LyDMWg+ri8BUcfUegseqBdeyuwcehBEiKyC6xUcE zM1XdrkEX9ZfcGO7x5l0egPhn+eSuj4p6N7gFtnC5jlDUWnZku1wO+lu8B/5olf5 tqfYmd6Hcyqz6lkMiWuhX8aSmGuS/QS2Fe2h87rEEQKBgQDyPGfhYj0fcel7XnER bWZSZ5XNwS8vCbBHP269aehPQQo32sGH+WGkxbXotlKK5hEUSxMR42xsDL0IAcGu CuDA1Czu+fj7FSHuerd73xH4yEoSxgy4G2vvnUCvqPwlQtPyZDKYRepeqy2V1h+y 1YrpeukO3enSPmH8+mgGYlGl3QKBgQDeoJ34teTCdIRm4v6o11JOX+k/BqkkMzAB t67stOQXatgeWIeYp49cZCXeIca83M/iINImpBr5sx681VvN84JEznkhAa1Wb/mk TUdhbHQAr13a15T7QDG81ejF5ifmxVfewPNq0REmIs1fc0x7zX4Ey1x8y4R1fy4X H84k1bKJgwKBgAo3Wfo7dnB5EWvOk940Svh2ve6rkx3cvr6Cgl0itlWBXLj2VOsz LVcRr5Zc+iY5hcbhU7CRcuUrtF0+FbkNZGU9jZeWm1Wbko7IRizHP67KY7Ve/PJW 1bqJW00NR3Ua2G2EpE2fxT6w4X9MRJH6R52JPYMPAOmJEADnXrPGOcNRAoGAKG5H AioWd3ItsXm8AfHI0s78TyPoh9h7+XPgYsCfQ9l1kl1FkuWrVX4immrL6vS3FDwd rkLTW1G6XVTqLUbx+4j72pCxaCdB0SLvubO2hYFTrDDGr7KC1eaLNZWM3Y4tXRjx nA6H7MMZRSJtW3aAUmKUU12qmqQUPMLb7ziYCf0CgYAw0n5WJHQl5rpFOwty72ZQ nqoFu2azM8Jm/IvftK/XXZIxRT50Bw6Dc4KYSR+VkLE9RibHlXuP2Lp5EaRO3SpH 3GsojUPifphft3gcxgBtPGtQwdCFOkIV/wwD6Q0Z7wQ+BVM6VifKTm1lSjDMS8aX F6hGTBUc7Nmgz4+kh9cOWA== -----END PRIVATE KEY----- ================================================ FILE: internal/testresources/client-cert.pem ================================================ -----BEGIN CERTIFICATE----- MIIDETCCAfmgAwIBAgIUU8qhWScbHKzCU94Gy+xdN9jltYEwDQYJKoZIhvcNAQEL BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MTAxMDE5MDIzNFoXDTM1 MTAwODE5MDIzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B AQEFAAOCAQ8AMIIBCgKCAQEA0qhdUMJlbk39u1A2MyFsOxXtpOYQui7iK7rK+c3V E6D+m9ca4dztERfBjiSaCryW4nqbZ0cVsVgmxwYWZxM539jV+QnZ9C28aQ+CBOeP UzScgajbX4EUpk6w5WPNcSVTiwchcfm9eOPHwlBUt+KjYbQqHRkKvJGH6dqWeGfR m8CWSVaTZQC/Dn8J795ktW/y0oZ4YbHBbkc64VVdVFbPvXxFnMEg6fqV5+K1f3DE GU5yHJPSmL5avq5FO+vklaVihMDRX58O777cJuZ5a4irY5P+pD4qwJ1gZfCc3YXG XHye+Vqu6a2UeM1wekue4dlJ5SQXzT4TDbORwxq8NjUlFwIDAQABo1gwVjAUBgNV HREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFLmx5zCvEt71ffphWJRZ2kPlzJiA MB8GA1UdIwQYMBaAFL4tkap6WA1XMjgHtKjzk4c7ZnIzMA0GCSqGSIb3DQEBCwUA A4IBAQAwtr0bTiaASWguBxu2xUvuXpm8ONmi3Ekts9+kJLVHPHdntfEbW2p/45Lx j0nCVduOkyb7AbPez02vW3orW4NTAJL8SPmvAqk0+ClTlm7RTvp/iOZcRaxOrZES 2S66rELSd386264RX644sJF1mwFmFuO4UJ/w9qmlDxCO9n8pm7SXDTYKsXsDO7BY cuBYFOhMD5ECeCP7/dyJNLzt63S5k+SmxaE17FWfRi6I4OE1vcxRAGpsCE7CYhW8 xYXnM0+AMf8nxblcTVls1moV3hk0P7XstO2gi9nZubav/cZigWwzFpAyoimTFDFe bKlHpNaD1H3ossbQzS222GTvrZQm -----END CERTIFICATE----- ================================================ FILE: internal/testutils/endpoint.go ================================================ /* Copyright 2017 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 testutils import ( "fmt" "maps" "math/rand" "net/netip" "reflect" "slices" "sort" "strings" "testing" "github.com/stretchr/testify/assert" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/events" ) /** test utility functions for endpoints verifications */ type byNames endpoint.ProviderSpecific func (p byNames) Len() int { return len(p) } func (p byNames) Swap(i, j int) { p[i], p[j] = p[j], p[i] } func (p byNames) Less(i, j int) bool { return p[i].Name < p[j].Name } type byAllFields []*endpoint.Endpoint func (b byAllFields) Len() int { return len(b) } func (b byAllFields) Swap(i, j int) { b[i], b[j] = b[j], b[i] } func (b byAllFields) Less(i, j int) bool { if b[i].DNSName < b[j].DNSName { return true } if b[i].DNSName == b[j].DNSName { // This rather bad, we need a more complex comparison for Targets, which considers all elements if b[i].Targets.Same(b[j].Targets) { if b[i].RecordType == (b[j].RecordType) { sa := b[i].ProviderSpecific sb := b[j].ProviderSpecific sort.Sort(byNames(sa)) sort.Sort(byNames(sb)) return reflect.DeepEqual(sa, sb) } return b[i].RecordType <= b[j].RecordType } return b[i].Targets.String() <= b[j].Targets.String() } return false } // SameEndpoint returns true if two endpoints are same // considers example.org. and example.org DNSName/Target as different endpoints func SameEndpoint(a, b *endpoint.Endpoint) bool { if a == nil || b == nil { return a == b } return a.DNSName == b.DNSName && a.Targets.Same(b.Targets) && a.RecordType == b.RecordType && a.SetIdentifier == b.SetIdentifier && a.Labels[endpoint.OwnerLabelKey] == b.Labels[endpoint.OwnerLabelKey] && a.RecordTTL == b.RecordTTL && a.Labels[endpoint.ResourceLabelKey] == b.Labels[endpoint.ResourceLabelKey] && a.Labels[endpoint.OwnedRecordLabelKey] == b.Labels[endpoint.OwnedRecordLabelKey] && SameProviderSpecific(a.ProviderSpecific, b.ProviderSpecific) } // SameEndpoints compares two slices of endpoints regardless of order // [x,y,z] == [z,x,y] // [x,x,z] == [x,z,x] // [x,y,y] != [x,x,y] // [x,x,x] != [x,x,z] func SameEndpoints(a, b []*endpoint.Endpoint) bool { if len(a) != len(b) { return false } sa := a sb := b sort.Sort(byAllFields(sa)) sort.Sort(byAllFields(sb)) for i := range sa { if !SameEndpoint(sa[i], sb[i]) { return false } } return true } // SameEndpointLabels verifies that labels of the two slices of endpoints are the same func SameEndpointLabels(a, b []*endpoint.Endpoint) bool { if len(a) != len(b) { return false } sa := a sb := b sort.Sort(byAllFields(sa)) sort.Sort(byAllFields(sb)) for i := range sa { if !reflect.DeepEqual(sa[i].Labels, sb[i].Labels) { return false } } return true } // SamePlanChanges verifies that two set of changes are the same func SamePlanChanges(a, b map[string][]*endpoint.Endpoint) bool { return SameEndpoints(a["Create"], b["Create"]) && SameEndpoints(a["Delete"], b["Delete"]) && SameEndpoints(a["UpdateOld"], b["UpdateOld"]) && SameEndpoints(a["UpdateNew"], b["UpdateNew"]) } // SameProviderSpecific verifies that two maps contain the same string/string key/value pairs func SameProviderSpecific(a, b endpoint.ProviderSpecific) bool { sa := a sb := b sort.Sort(byNames(sa)) sort.Sort(byNames(sb)) return reflect.DeepEqual(sa, sb) } // NewTargetsFromAddr convert an array of netip.Addr to Targets (array of string) func NewTargetsFromAddr(targets []netip.Addr) endpoint.Targets { t := make(endpoint.Targets, len(targets)) for i, target := range targets { t[i] = target.String() } return t } // GenerateTestEndpointsByType generates a shuffled slice of test Endpoints for each record type and count specified in typeCounts. // Usage example: // // endpoints := GenerateTestEndpointsByType(map[string]int{"A": 2, "CNAME": 1}) // endpoints will contain 2 A records and 1 CNAME record with unique DNS names and targets. func GenerateTestEndpointsByType(typeCounts map[string]int) []*endpoint.Endpoint { return GenerateTestEndpointsWithDistribution(typeCounts, map[string]int{"example.com": 1}, nil) } // GenerateTestEndpointsWithDistribution generates test endpoints with specified distributions // of record types, domains, and owners. // - typeCounts: maps record type (e.g., "A", "CNAME") to how many endpoints of that type to create // - domainWeights: maps domain suffix to weight; domains are distributed proportionally // - ownerWeights: maps owner ID to weight; owners are distributed proportionally // // The total number of endpoints equals the sum of typeCounts values. // Weights represent ratios: {"example.com": 2, "test.org": 1} means ~66% example.com, ~33% test.org // // Example: // // endpoints := GenerateTestEndpointsWithDistribution( // map[string]int{"A": 6, "CNAME": 4}, // 10 endpoints total // map[string]int{"example.com": 2, "test.org": 1}, // ~66% example.com, ~33% test.org // map[string]int{"owner1": 3, "owner2": 1}, // ~75% owner1, ~25% owner2 // ) func GenerateTestEndpointsWithDistribution( typeCounts map[string]int, domainWeights map[string]int, ownerWeights map[string]int, ) []*endpoint.Endpoint { // Calculate total endpoints totalEndpoints := 0 for _, count := range typeCounts { totalEndpoints += count } // Build domain distribution (sorted keys for determinism) domainKeys := slices.Sorted(maps.Keys(domainWeights)) domains := distributeByWeight(domainKeys, domainWeights, totalEndpoints) // Build owner distribution (sorted keys for determinism) ownerKeys := slices.Sorted(maps.Keys(ownerWeights)) owners := distributeByWeight(ownerKeys, ownerWeights, totalEndpoints) // Sort record types for deterministic iteration typeKeys := slices.Sorted(maps.Keys(typeCounts)) var result []*endpoint.Endpoint idx := 0 for _, rt := range typeKeys { count := typeCounts[rt] for range count { // Determine domain from distribution or use default domain := "example.com" if idx < len(domains) { domain = domains[idx] } // Create endpoint with labels ep := &endpoint.Endpoint{ DNSName: fmt.Sprintf("%s-%d.%s", strings.ToLower(rt), idx, domain), Targets: endpoint.Targets{fmt.Sprintf("192.0.2.%d", idx)}, RecordType: rt, RecordTTL: 300, Labels: endpoint.Labels{}, } // Assign owner from distribution if idx < len(owners) { ep.Labels[endpoint.OwnerLabelKey] = owners[idx] } result = append(result, ep) idx++ } } rand.Shuffle(len(result), func(i, j int) { result[i], result[j] = result[j], result[i] }) return result } // NewEndpointWithRef builds an endpoint attached to a Kubernetes object reference. // The record type is inferred from target: A for IPv4, AAAA for IPv6, CNAME otherwise. // Kind and APIVersion are resolved from the client-go scheme, so TypeMeta need not be set on obj. func NewEndpointWithRef(dns, target string, obj ctrlclient.Object, source string) *endpoint.Endpoint { return endpoint.NewEndpoint(dns, endpoint.SuitableType(target), target). WithRefObject(events.NewObjectReference(obj, source)) } // AssertEndpointsHaveRefObject asserts that endpoints have the expected count // and each endpoint has a non-nil RefObject with the expected source type. func AssertEndpointsHaveRefObject( t *testing.T, endpoints []*endpoint.Endpoint, expectedSource string, expectedCount int) { t.Helper() assert.Len(t, endpoints, expectedCount) for _, ep := range endpoints { assert.NotNil(t, ep.RefObject()) assert.NotEmpty(t, ep.RefObject().UID) assert.Equal(t, expectedSource, ep.RefObject().Source) } } // distributeByWeight distributes n items according to weights. // Returns a slice of length n with items distributed proportionally. func distributeByWeight(keys []string, weights map[string]int, n int) []string { if len(keys) == 0 || n == 0 { return nil } totalWeight := 0 for _, key := range keys { totalWeight += weights[key] } if totalWeight == 0 { return nil } result := make([]string, 0, n) for _, key := range keys { count := (weights[key] * n) / totalWeight for range count { result = append(result, key) } } // Fill any remaining slots due to rounding with the last key for len(result) < n { result = append(result, keys[len(keys)-1]) } return result } ================================================ FILE: internal/testutils/endpoint_test.go ================================================ /* Copyright 2017 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 testutils import ( "net/netip" "reflect" "sort" "strings" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) func TestExampleSameEndpoints(t *testing.T) { eps := []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"load-balancer.org"}, }, { DNSName: "example.org", Targets: endpoint.Targets{"load-balancer.org"}, RecordType: endpoint.RecordTypeTXT, }, { DNSName: "abc.com", Targets: endpoint.Targets{"something"}, RecordType: endpoint.RecordTypeTXT, }, { DNSName: "abc.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "test-set-1", }, { DNSName: "bbc.com", Targets: endpoint.Targets{"foo.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "cbc.com", Targets: endpoint.Targets{"foo.com"}, RecordType: "CNAME", RecordTTL: endpoint.TTL(60), }, { DNSName: "example.org", Targets: endpoint.Targets{"load-balancer.org"}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{Name: "foo", Value: "bar"}, }, }, } sort.Sort(byAllFields(eps)) expectedOrder := []string{ "abc.com", "abc.com", "bbc.com", "cbc.com", "example.org", "example.org", "example.org", } assert.Len(t, eps, len(expectedOrder)) for i, ep := range eps { assert.Equal(t, expectedOrder[i], ep.DNSName, "endpoint %d should be %s", i, expectedOrder[i]) } } func makeEndpoint(DNSName string) *endpoint.Endpoint { // nolint: gocritic // captLocal return &endpoint.Endpoint{ DNSName: DNSName, Targets: endpoint.Targets{"target.com"}, RecordType: "A", SetIdentifier: "set1", RecordTTL: 300, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", endpoint.ResourceLabelKey: "resource", endpoint.OwnedRecordLabelKey: "owned", }, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "key", Value: "val"}, }, } } func TestSameEndpoint(t *testing.T) { tests := []struct { name string a *endpoint.Endpoint b *endpoint.Endpoint isSameEndpoint bool }{ { name: "DNSName is not equal", a: &endpoint.Endpoint{DNSName: "example.org"}, b: &endpoint.Endpoint{DNSName: "example.com"}, isSameEndpoint: false, }, { name: "All fields are equal", a: &endpoint.Endpoint{ DNSName: "example.org", Targets: endpoint.Targets{"lb.example.com"}, RecordType: "A", SetIdentifier: "set-1", RecordTTL: 300, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner-1", endpoint.ResourceLabelKey: "resource-1", endpoint.OwnedRecordLabelKey: "owned-true", }, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "key1", Value: "val1"}, }, }, b: &endpoint.Endpoint{ DNSName: "example.org", Targets: endpoint.Targets{"lb.example.com"}, RecordType: "A", SetIdentifier: "set-1", RecordTTL: 300, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner-1", endpoint.ResourceLabelKey: "resource-1", endpoint.OwnedRecordLabelKey: "owned-true", }, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "key1", Value: "val1"}, }, }, isSameEndpoint: true, }, { name: "Different Targets", a: &endpoint.Endpoint{DNSName: "example.org", Targets: endpoint.Targets{"a.com"}}, b: &endpoint.Endpoint{DNSName: "example.org", Targets: endpoint.Targets{"b.com"}}, isSameEndpoint: false, }, { name: "Different RecordType", a: &endpoint.Endpoint{DNSName: "example.org", RecordType: "A"}, b: &endpoint.Endpoint{DNSName: "example.org", RecordType: "CNAME"}, isSameEndpoint: false, }, { name: "Different SetIdentifier", a: &endpoint.Endpoint{DNSName: "example.org", SetIdentifier: "id1"}, b: &endpoint.Endpoint{DNSName: "example.org", SetIdentifier: "id2"}, isSameEndpoint: false, }, { name: "Different OwnerLabelKey", a: &endpoint.Endpoint{ DNSName: "example.org", Labels: map[string]string{ endpoint.OwnerLabelKey: "owner1", }, }, b: &endpoint.Endpoint{ DNSName: "example.org", Labels: map[string]string{ endpoint.OwnerLabelKey: "owner2", }, }, isSameEndpoint: false, }, { name: "Different RecordTTL", a: &endpoint.Endpoint{DNSName: "example.org", RecordTTL: 300}, b: &endpoint.Endpoint{DNSName: "example.org", RecordTTL: 400}, isSameEndpoint: false, }, { name: "Different ProviderSpecific", a: &endpoint.Endpoint{ DNSName: "example.org", ProviderSpecific: endpoint.ProviderSpecific{ {Name: "key1", Value: "val1"}, }, }, b: &endpoint.Endpoint{ DNSName: "example.org", ProviderSpecific: endpoint.ProviderSpecific{ {Name: "key1", Value: "val2"}, }, }, isSameEndpoint: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isSameEndpoint := SameEndpoint(tt.a, tt.b) assert.Equal(t, tt.isSameEndpoint, isSameEndpoint) }) } } func TestSameEndpoints(t *testing.T) { tests := []struct { name string a, b []*endpoint.Endpoint want bool }{ { name: "Both slices nil", a: nil, b: nil, want: true, }, { name: "One nil, one empty", a: []*endpoint.Endpoint{}, b: nil, want: true, }, { name: "Different lengths", a: []*endpoint.Endpoint{makeEndpoint("a.com")}, b: []*endpoint.Endpoint{}, want: false, }, { name: "Same endpoints in same order", a: []*endpoint.Endpoint{makeEndpoint("a.com"), makeEndpoint("b.com")}, b: []*endpoint.Endpoint{makeEndpoint("a.com"), makeEndpoint("b.com")}, want: true, }, { name: "Same endpoints in different order", a: []*endpoint.Endpoint{makeEndpoint("b.com"), makeEndpoint("a.com")}, b: []*endpoint.Endpoint{makeEndpoint("a.com"), makeEndpoint("b.com")}, want: true, }, { name: "One endpoint differs", a: []*endpoint.Endpoint{makeEndpoint("a.com"), makeEndpoint("b.com")}, b: []*endpoint.Endpoint{makeEndpoint("a.com"), makeEndpoint("c.com")}, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isSameEndpoints := SameEndpoints(tt.a, tt.b) assert.Equal(t, tt.want, isSameEndpoints) }) } } func TestSameEndpointLabel(t *testing.T) { tests := []struct { name string a []*endpoint.Endpoint b []*endpoint.Endpoint want bool }{ { name: "length of a and b are not same", a: []*endpoint.Endpoint{makeEndpoint("a.com")}, b: []*endpoint.Endpoint{makeEndpoint("b.com"), makeEndpoint("c.com")}, want: false, }, { name: "endpoint's labels are same in a and b", a: []*endpoint.Endpoint{makeEndpoint("a.com"), makeEndpoint("c.com")}, b: []*endpoint.Endpoint{makeEndpoint("b.com"), makeEndpoint("c.com")}, want: true, }, { name: "endpoint's labels are not same in a and b", a: []*endpoint.Endpoint{ { DNSName: "a.com", Labels: endpoint.Labels{ endpoint.OwnerLabelKey: "owner1", endpoint.ResourceLabelKey: "resource1", }, }, { DNSName: "b.com", Labels: endpoint.Labels{ endpoint.OwnerLabelKey: "owner2", endpoint.ResourceLabelKey: "resource2", }, }, }, b: []*endpoint.Endpoint{ { DNSName: "a.com", Labels: endpoint.Labels{ endpoint.OwnerLabelKey: "owner", endpoint.ResourceLabelKey: "resource", }, }, { DNSName: "b.com", Labels: endpoint.Labels{ endpoint.OwnerLabelKey: "owner1", endpoint.ResourceLabelKey: "resource1", }, }, }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { isSameEndpointLabels := SameEndpointLabels(tt.a, tt.b) assert.Equal(t, tt.want, isSameEndpointLabels) }) } } func TestSamePlanChanges(t *testing.T) { tests := []struct { name string a map[string][]*endpoint.Endpoint b map[string][]*endpoint.Endpoint want bool }{ { name: "endpoints with all operations in a and b are same", a: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("a.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, b: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("a.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, want: true, }, { name: "endpoints for create operations in a and b are not same", a: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("a.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, b: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("x.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("a.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, want: false, }, { name: "endpoints for delete operations in a and b are not same", a: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("a.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, b: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("g.com")}, "UpdateOld": {makeEndpoint("a.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, want: false, }, { name: "endpoints for updateOld operations in a and b are not same", a: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("b.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, b: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("c.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, want: false, }, { name: "endpoints for updateNew operations in a and b are same", a: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("a.com")}, "UpdateNew": {makeEndpoint("d.com")}, }, b: map[string][]*endpoint.Endpoint{ "Create": {makeEndpoint("a.com")}, "Delete": {makeEndpoint("b.com")}, "UpdateOld": {makeEndpoint("a.com")}, "UpdateNew": {makeEndpoint("c.com")}, }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { checkPlanChanges := SamePlanChanges(tt.a, tt.b) assert.Equal(t, tt.want, checkPlanChanges) }) } } func TestNewTargetsFromAddr(t *testing.T) { tests := []struct { name string input []netip.Addr expected endpoint.Targets }{ { name: "empty slice", input: []netip.Addr{}, expected: endpoint.Targets{}, }, { name: "single IPv4 address", input: []netip.Addr{ netip.MustParseAddr("192.0.2.1"), }, expected: endpoint.Targets{"192.0.2.1"}, }, { name: "multiple IP addresses", input: []netip.Addr{ netip.MustParseAddr("192.0.2.1"), netip.MustParseAddr("2001:db8::1"), }, expected: endpoint.Targets{"192.0.2.1", "2001:db8::1"}, }, { name: "IPv6 address only", input: []netip.Addr{ netip.MustParseAddr("::1"), }, expected: endpoint.Targets{"::1"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := NewTargetsFromAddr(tt.input) if !reflect.DeepEqual(got, tt.expected) { t.Errorf("NewTargetsFromAddr() = %v, want %v", got, tt.expected) } }) } } func TestWithLabel(t *testing.T) { e := &endpoint.Endpoint{} // should initialize Labels and set the key returned := e.WithLabel("foo", "bar") assert.Equal(t, e, returned) assert.NotNil(t, e.Labels) assert.Equal(t, "bar", e.Labels["foo"]) // overriding an existing key e2 := e.WithLabel("foo", "baz") assert.Equal(t, e, e2) assert.Equal(t, "baz", e.Labels["foo"]) // adding a new key without wiping others e.Labels["existing"] = "orig" e.WithLabel("new", "val") assert.Equal(t, "orig", e.Labels["existing"]) assert.Equal(t, "val", e.Labels["new"]) } func TestGenerateTestEndpointsWithDistribution(t *testing.T) { tests := []struct { name string typeCounts map[string]int domainWeights map[string]int ownerWeights map[string]int wantTotal int wantTypes map[string]int wantDomains map[string]int wantOwners map[string]int }{ { name: "basic distribution", typeCounts: map[string]int{"A": 6, "CNAME": 4}, domainWeights: map[string]int{"example.com": 1, "test.org": 1}, ownerWeights: map[string]int{"owner1": 1, "owner2": 1}, wantTotal: 10, wantTypes: map[string]int{"A": 6, "CNAME": 4}, wantDomains: map[string]int{"example.com": 5, "test.org": 5}, wantOwners: map[string]int{"owner1": 5, "owner2": 5}, }, { name: "weighted distribution 2:1", typeCounts: map[string]int{"A": 9}, domainWeights: map[string]int{"example.com": 2, "test.org": 1}, ownerWeights: map[string]int{"owner1": 2, "owner2": 1}, wantTotal: 9, wantTypes: map[string]int{"A": 9}, wantDomains: map[string]int{"example.com": 6, "test.org": 3}, wantOwners: map[string]int{"owner1": 6, "owner2": 3}, }, { name: "empty weights use defaults", typeCounts: map[string]int{"A": 3}, domainWeights: map[string]int{}, ownerWeights: map[string]int{}, wantTotal: 3, wantTypes: map[string]int{"A": 3}, wantDomains: map[string]int{"example.com": 3}, wantOwners: map[string]int{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { eps := GenerateTestEndpointsWithDistribution(tt.typeCounts, tt.domainWeights, tt.ownerWeights) assert.Len(t, eps, tt.wantTotal, "total endpoint count") // Count actual distributions gotTypes := make(map[string]int) gotDomains := make(map[string]int) gotOwners := make(map[string]int) for _, ep := range eps { gotTypes[ep.RecordType]++ for domain := range tt.wantDomains { if strings.HasSuffix(ep.DNSName, domain) { gotDomains[domain]++ break } } if owner, ok := ep.Labels[endpoint.OwnerLabelKey]; ok { gotOwners[owner]++ } } assert.Equal(t, tt.wantTypes, gotTypes, "record type distribution") assert.Equal(t, tt.wantDomains, gotDomains, "domain distribution") assert.Equal(t, tt.wantOwners, gotOwners, "owner distribution") }) } } func TestFilterEndpointsByOwnerIDLogging(t *testing.T) { noOwner := &endpoint.Endpoint{} ownedByFoo := &endpoint.Endpoint{ Labels: endpoint.Labels{ endpoint.OwnerLabelKey: "foo", }, } ownedByBar := &endpoint.Endpoint{ Labels: endpoint.Labels{ endpoint.OwnerLabelKey: "bar", }, } tests := []struct { name string ownerID string endpoints []*endpoint.Endpoint messages []string messages_not []string result []*endpoint.Endpoint }{ { name: "one_matches", ownerID: "foo", endpoints: []*endpoint.Endpoint{ownedByFoo}, messages: []string{}, messages_not: []string{""}, result: []*endpoint.Endpoint{ownedByFoo}, }, { name: "wrong_owner", ownerID: "foo", endpoints: []*endpoint.Endpoint{ownedByFoo, ownedByBar}, messages: []string{"because owner id does not match"}, messages_not: []string{}, result: []*endpoint.Endpoint{ownedByFoo}, }, { name: "no_owner", ownerID: "bar", endpoints: []*endpoint.Endpoint{noOwner, ownedByBar}, messages: []string{"because of missing owner label"}, messages_not: []string{"because owner id does not match"}, result: []*endpoint.Endpoint{ownedByBar}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) endpoint.FilterEndpointsByOwnerID(tt.ownerID, tt.endpoints) for _, m := range tt.messages { logtest.TestHelperLogContains(m, hook, t) } for _, m := range tt.messages_not { logtest.TestHelperLogNotContains(m, hook, t) } }) } } ================================================ FILE: internal/testutils/env.go ================================================ /* 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 testutils import ( "testing" ) func TestHelperEnvSetter(t *testing.T, envs map[string]string) { t.Helper() for name, value := range envs { t.Setenv(name, value) } } ================================================ FILE: internal/testutils/helpers.go ================================================ /* 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 testutils // ToPtr returns a pointer to the given value of any type. // Example usage: // // foo := 42 // fooPtr := ToPtr(foo) // fmt.Println(*fooPtr) // Output: 42 func ToPtr[T any](v T) *T { return &v } ================================================ FILE: internal/testutils/helpers_test.go ================================================ /* 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 testutils import ( "testing" "github.com/stretchr/testify/assert" ) func TestStringPtr(t *testing.T) { original := "hello" ptr := ToPtr(original) if ptr == nil { t.Fatal("StringPtr returned nil") } if *ptr != original { t.Fatalf("expected %q, got %q", original, *ptr) } // Ensure the pointer value is independent of the original variable original = "world" if *ptr == original { t.Error("pointer value changed with the original variable") } } func TestIsPointer(t *testing.T) { value := "test" ptr := ToPtr(value) assert.IsType(t, *ptr, value) } ================================================ FILE: internal/testutils/init.go ================================================ /* Copyright 2020 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 testutils import ( "io" "log" "os" "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/internal/config" ) func init() { config.FastPoll = true if os.Getenv("DEBUG") == "" { logrus.SetOutput(io.Discard) log.SetOutput(io.Discard) } else { if level, err := logrus.ParseLevel(os.Getenv("DEBUG")); err == nil { logrus.SetLevel(level) } } } ================================================ FILE: internal/testutils/log/log.go ================================================ /* 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 logtest import ( "strings" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/sirupsen/logrus/hooks/test" ) // LogsUnderTestWithLogLevel redirects log(s) output to a buffer for testing purposes // // Usage: LogsUnderTestWithLogLevel(t) // Example: // // hook := testutils.LogsUnderTestWithLogLevel(log.DebugLevel, t) // ... do something that logs ... // // testutils.TestHelperLogContains("expected debug log message", hook, t) func LogsUnderTestWithLogLevel(level log.Level, t *testing.T) *test.Hook { t.Helper() logger, hook := test.NewNullLogger() log.AddHook(hook) log.SetOutput(logger.Out) log.SetLevel(level) return hook } // TestHelperLogContains verifies that a specific log message is present // in the captured log entries. It asserts that the provided message `msg` // appears in at least one of the log entries recorded by the `hook`. // // Parameters: // - msg: The log message that should be found. // - hook: The test hook capturing log entries. // - t: The testing object used for assertions. // // Usage: // This helper is used in tests to ensure that certain log messages are // logged during the execution of the code under test. func TestHelperLogContains(msg string, hook *test.Hook, t *testing.T) { t.Helper() isContains := false for _, entry := range hook.AllEntries() { if strings.Contains(entry.Message, msg) { isContains = true } } assert.True(t, isContains, "Expected log message not found: %s", msg) } // TestHelperLogNotContains verifies that a specific log message is not present // in the captured log entries. It asserts that the provided message `msg` // does not appear in any of the log entries recorded by the `hook`. // // Parameters: // - msg: The log message that should not be found. // - hook: The test hook capturing log entries. // - t: The testing object used for assertions. // // Usage: // This helper is used in tests to ensure that certain log messages are not // logged during the execution of the code under test. func TestHelperLogNotContains(msg string, hook *test.Hook, t *testing.T) { t.Helper() isContains := false for _, entry := range hook.AllEntries() { if strings.Contains(entry.Message, msg) { isContains = true } } assert.False(t, isContains, "Expected log message found when should not: %s", msg) } // TestHelperLogContainsWithLogLevel verifies that a specific log message with a given log level // is present in the captured log entries. It asserts that the provided message `msg` // appears in at least one of the log entries recorded by the `hook` with the specified log level. // // Parameters: // - msg: The log message that should be found. // - level: The log level that the message should have. // - hook: The test hook capturing log entries. // - t: The testing object used for assertions. // // Usage: // This helper is used in tests to ensure that certain log messages with a specific log level // are logged during the execution of the code under test. func TestHelperLogContainsWithLogLevel(msg string, level log.Level, hook *test.Hook, t *testing.T) { t.Helper() isContains := false for _, entry := range hook.AllEntries() { if strings.Contains(entry.Message, msg) && entry.Level == level { isContains = true } } assert.True(t, isContains, "Expected log message not found: %s with level %s", msg, level) } // TestHelperWithLogExitFunc overrides the logrus ExitFunc for the duration of a test. // It returns a restore function that resets the ExitFunc back to the previous value. func TestHelperWithLogExitFunc(exitFunc func(int)) func() { logger := log.StandardLogger() previousExitFunc := logger.ExitFunc logger.ExitFunc = exitFunc return func() { logger.ExitFunc = previousExitFunc } } ================================================ FILE: internal/testutils/metrics.go ================================================ /* 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 testutils import ( "fmt" "sort" "strings" "testing" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" ) // TestHelperVerifyMetricsGaugeVectorWithLabels verifies that a prometheus.GaugeVec metric with specific labels has the expected value. // Supports partial label matching - if only some labels are provided, it sums all metrics matching those labels. // // Example usage: // // Exact match (all labels) // labels := map[string]string{"method": "GET", "status": "200"} // TestHelperVerifyMetricsGaugeVectorWithLabels(t, 42.0, myGaugeVec, labels) // // Partial match (sum all metrics with method=GET) // labels := map[string]string{"method": "GET"} // TestHelperVerifyMetricsGaugeVectorWithLabels(t, 100.0, myGaugeVec, labels) func TestHelperVerifyMetricsGaugeVectorWithLabels(t *testing.T, expected float64, metric prometheus.GaugeVec, labels map[string]string) { TestHelperVerifyMetricsGaugeVectorWithLabelsFunc(t, expected, assert.Equal, metric, labels) } // TestHelperVerifyMetricsGaugeVectorWithLabelsFunc is a helper function that verifies a prometheus.GaugeVec metric with specific labels using a custom assertion function. // Supports partial label matching - if only some labels are provided, it sums all metrics matching those labels. func TestHelperVerifyMetricsGaugeVectorWithLabelsFunc(t *testing.T, expected float64, aFunc assert.ComparisonAssertionFunc, metric prometheus.GaugeVec, labels map[string]string) { t.Helper() // Collect all metrics and find matching ones actual := sumMetricsWithLabels(&metric, labels) if !aFunc(t, expected, actual, "Expected gauge value does not match the actual value", labels) { t.Logf("Available metrics:\n%s", collectGaugeVecMetrics(&metric)) } } // collectAll drains all current observations from a GaugeVec into a slice. func collectAll(metric *prometheus.GaugeVec) []*dto.Metric { ch := make(chan prometheus.Metric, 1024) go func() { metric.Collect(ch) close(ch) }() var result []*dto.Metric for m := range ch { var dm dto.Metric if err := m.Write(&dm); err != nil { continue } result = append(result, &dm) } return result } // sumMetricsWithLabels sums all metric values that match the provided labels (partial match supported). // Label matching is case-insensitive since metrics are stored in lowercase. func sumMetricsWithLabels(metric *prometheus.GaugeVec, matchLabels map[string]string) float64 { var sum float64 for _, dm := range collectAll(metric) { // Check if all matchLabels are present with correct values (case-insensitive) metricLabels := make(map[string]string) for _, lp := range dm.Label { metricLabels[lp.GetName()] = lp.GetValue() } matches := true for k, v := range matchLabels { if !strings.EqualFold(metricLabels[k], v) { matches = false break } } if matches && dm.Gauge != nil { sum += dm.Gauge.GetValue() } } return sum } // collectGaugeVecMetrics collects all metrics from a GaugeVec and returns a formatted string. // Shows both per-label aggregates and detailed metrics. func collectGaugeVecMetrics(metric *prometheus.GaugeVec) string { // Collect all metrics and aggregate by label type metricEntry struct { labels map[string]string value float64 } var entries []metricEntry aggregates := make(map[string]map[string]float64) // labelName -> labelValue -> sum for _, dm := range collectAll(metric) { entry := metricEntry{labels: make(map[string]string)} for _, lp := range dm.Label { name, value := lp.GetName(), lp.GetValue() entry.labels[name] = value if aggregates[name] == nil { aggregates[name] = make(map[string]float64) } if dm.Gauge != nil { aggregates[name][value] += dm.Gauge.GetValue() } } if dm.Gauge != nil { entry.value = dm.Gauge.GetValue() } entries = append(entries, entry) } if len(entries) == 0 { return " (no metrics collected)" } var sb strings.Builder // Output aggregates by label (sorted) sb.WriteString("Totals by label:\n") var labelNames []string for name := range aggregates { labelNames = append(labelNames, name) } sort.Strings(labelNames) for _, name := range labelNames { values := aggregates[name] var pairs []string for v, sum := range values { pairs = append(pairs, fmt.Sprintf("%s=%.0f", v, sum)) } sort.Strings(pairs) sb.WriteString(fmt.Sprintf(" %s: %s\n", name, strings.Join(pairs, ", "))) } // Output detailed metrics sb.WriteString("\nAll metrics:\n") for _, e := range entries { var labels []string for k, v := range e.labels { labels = append(labels, fmt.Sprintf("%s=%q", k, v)) } sort.Strings(labels) sb.WriteString(fmt.Sprintf(" {%s} = %.2f\n", strings.Join(labels, ", "), e.value)) } return sb.String() } ================================================ FILE: internal/testutils/mock_source.go ================================================ /* Copyright 2017 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 testutils import ( "context" "time" "github.com/stretchr/testify/mock" "sigs.k8s.io/external-dns/endpoint" ) // MockSource returns mock endpoints. type MockSource struct { mock.Mock endpoints []*endpoint.Endpoint } func NewMockSource(endpoints ...*endpoint.Endpoint) *MockSource { m := &MockSource{ endpoints: endpoints, } m.On("Endpoints").Return(endpoints, nil) m.On("AddEventHandler", mock.AnythingOfType("*context.cancelCtx")).Return() return m } // Endpoints returns the desired mock endpoints. func (m *MockSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { args := m.Called() endpoints := args.Get(0) if endpoints == nil { return nil, args.Error(1) } return endpoints.([]*endpoint.Endpoint), args.Error(1) } // AddEventHandler adds an event handler that should be triggered if something in source changes func (m *MockSource) AddEventHandler(ctx context.Context, handler func()) { m.Called(ctx) if handler == nil { return } go func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: handler() } } }() } ================================================ FILE: kustomize/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - kustomize ================================================ FILE: kustomize/external-dns-clusterrole.yaml ================================================ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: external-dns rules: - apiGroups: [""] resources: ["pods", "services"] verbs: ["get", "watch", "list"] - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get", "watch", "list"] - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get", "watch", "list"] - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] verbs: ["get", "watch", "list"] - apiGroups: [""] resources: ["nodes"] verbs: ["watch", "list"] ================================================ FILE: kustomize/external-dns-clusterrolebinding.yaml ================================================ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: external-dns-viewer roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: external-dns subjects: - kind: ServiceAccount name: external-dns namespace: default ================================================ FILE: kustomize/external-dns-deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: strategy: type: Recreate selector: matchLabels: app: external-dns template: metadata: labels: app: external-dns spec: serviceAccountName: external-dns containers: - name: external-dns image: registry.k8s.io/external-dns/external-dns args: - --source=service - --source=ingress - --registry=txt ================================================ FILE: kustomize/external-dns-serviceaccount.yaml ================================================ apiVersion: v1 kind: ServiceAccount metadata: name: external-dns ================================================ FILE: kustomize/kustomization.yaml ================================================ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: registry.k8s.io/external-dns/external-dns newTag: v0.20.0 resources: - ./external-dns-deployment.yaml - ./external-dns-serviceaccount.yaml - ./external-dns-clusterrole.yaml - ./external-dns-clusterrolebinding.yaml ================================================ FILE: main.go ================================================ /* Copyright 2017 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 main import ( _ "k8s.io/client-go/plugin/pkg/client/auth" "sigs.k8s.io/external-dns/controller" ) func main() { controller.Execute() } ================================================ FILE: mkdocs.yml ================================================ site_name: external-dns site_author: external-dns maintainers repo_name: kubernetes-sigs/external-dns repo_url: https://github.com/kubernetes-sigs/external-dns/ docs_dir: . nav: - README.md - Chart: - About: charts/external-dns/README.md - Changelog: charts/external-dns/CHANGELOG.md - About: - FAQ: docs/faq.md - Flags: docs/flags.md - Out of Incubator: docs/20190708-external-dns-incubator.md - Code of Conduct: code-of-conduct.md - License: LICENSE.md - Providers: docs/providers.md - Version Update: docs/version-update-playbook.md - Tutorials: docs/tutorials/* - Annotations: - About: docs/annotations/annotations.md - Sources: docs/sources/* - Registries: - About: docs/registry/registry.md - TXT: docs/registry/txt.md - DynamoDB: docs/registry/dynamodb.md - Advanced Topics: - FQDN Templating: docs/advanced/fqdn-templating.md - Import Records: docs/advanced/import-records.md - Initial Design: docs/initial-design.md - Kubernetes Events: docs/advanced/events.md - Leader Election: docs/proposal/001-leader-election.md - Monitoring: docs/monitoring/* - MultiTarget: docs/proposal/multi-target.md - NAT64: docs/advanced/nat64.md - Rate Limits: docs/advanced/rate-limits.md - TTL: docs/advanced/ttl.md - Decisions: docs/proposal/0*.md - Domain Filter: docs/advanced/domain-filter.md - Configuration Precedence: docs/advanced/configuration-precedence.md - Split Horizon DNS: docs/advanced/split-horizon.md - Contributing: - Kubernetes Contributions: CONTRIBUTING.md - Release: docs/release.md - Deprecation Policy: docs/deprecation.md - docs/contributing/* theme: name: material custom_dir: docs/overrides features: - content.code.annotate - navigation.top - navigation.tracking - navigation.indexes - navigation.instant - navigation.tabs - navigation.tabs.sticky extra: version: provider: mike markdown_extensions: - meta - tables - toc: permalink: true - abbr - extra - admonition - smarty - nl2br - mdx_truly_sane_lists: nested_indent: 2 - attr_list - def_list - footnotes - md_in_html - pymdownx.arithmatex: generic: true - pymdownx.betterem: smart_enable: all - pymdownx.caret - pymdownx.details - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg - pymdownx.highlight: use_pygments: true anchor_linenums: true - pymdownx.inlinehilite - pymdownx.keys - pymdownx.mark - pymdownx.smartsymbols - pymdownx.snippets - pymdownx.superfences: custom_fences: - name: mermaid class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true - pymdownx.tilde - pymdownx.tasklist: custom_checkbox: true plugins: - same-dir - search - literate-nav - git-revision-date-localized: type: date fallback_to_build_date: true # https://mkdocs-macros-plugin.readthedocs.io/en/latest/ - macros: include_dir: docs/snippets # required, as default jinja markers are {{ and }} # ref: https://mkdocs-macros-plugin.readthedocs.io/en/latest/rendering/#solution-5-altering-the-syntax-of-jinja2-for-mkdocs-macros j2_block_start_string: '[[%' j2_block_end_string: '%]]' j2_variable_start_string: '[[' j2_variable_end_string: ']]' j2_comment_start_string: '[[#' j2_comment_end_string: '#]]' ================================================ FILE: pkg/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - pkg ================================================ FILE: pkg/apis/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - apis ================================================ FILE: pkg/apis/externaldns/constants.go ================================================ /* Copyright 2026 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 externaldns const ( RegistryTXT = "txt" RegistryNoop = "noop" RegistryDynamoDB = "dynamodb" RegistryAWSSD = "aws-sd" ProviderAkamai = "akamai" ProviderAlibabaCloud = "alibabacloud" ProviderAWS = "aws" ProviderAWSSD = "aws-sd" ProviderAzure = "azure" ProviderAzureDNS = "azure-dns" ProviderAzurePrivate = "azure-private-dns" ProviderCivo = "civo" ProviderCloudflare = "cloudflare" ProviderCoreDNS = "coredns" ProviderSkyDNS = "skydns" ProviderDNSimple = "dnsimple" ProviderExoscale = "exoscale" ProviderGandi = "gandi" ProviderGoDaddy = "godaddy" ProviderGoogle = "google" ProviderInMemory = "inmemory" ProviderLinode = "linode" ProviderNS1 = "ns1" ProviderOCI = "oci" ProviderOVH = "ovh" ProviderPDNS = "pdns" ProviderPihole = "pihole" ProviderPlural = "plural" ProviderRFC2136 = "rfc2136" ProviderScaleway = "scaleway" ProviderTransip = "transip" ProviderWebhook = "webhook" ) ================================================ FILE: pkg/apis/externaldns/types.go ================================================ /* Copyright 2017 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 externaldns import ( "fmt" "reflect" "regexp" "strings" "time" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/external-dns/internal/flags" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "github.com/alecthomas/kingpin/v2" "github.com/sirupsen/logrus" ) const ( passwordMask = "******" ) // Config is a project-wide configuration type Config struct { APIServerURL string KubeConfig string RequestTimeout time.Duration DefaultTargets []string GlooNamespaces []string SkipperRouteGroupVersion string Sources []string Namespace string AnnotationFilter string AnnotationPrefix string LabelFilter string IngressClassNames []string FQDNTemplate string TargetTemplate string FQDNTargetTemplate string CombineFQDNAndAnnotation bool IgnoreHostnameAnnotation bool IgnoreNonHostNetworkPods bool IgnoreIngressTLSSpec bool IgnoreIngressRulesSpec bool ListenEndpointEvents bool ExposeInternalIPV6 bool GatewayName string GatewayNamespace string GatewayLabelFilter string Compatibility string PodSourceDomain string PublishInternal bool PublishHostIP bool AlwaysPublishNotReadyAddresses bool ConnectorSourceServer string Provider string ProviderCacheTime time.Duration GoogleProject string GoogleBatchChangeSize int GoogleBatchChangeInterval time.Duration GoogleZoneVisibility string DomainFilter []string DomainExclude []string RegexDomainFilter *regexp.Regexp RegexDomainExclude *regexp.Regexp ZoneNameFilter []string ZoneIDFilter []string TargetNetFilter []string ExcludeTargetNets []string AlibabaCloudConfigFile string AlibabaCloudZoneType string AWSZoneType string AWSZoneTagFilter []string AWSAssumeRole string AWSProfiles []string AWSAssumeRoleExternalID string `secure:"yes"` AWSBatchChangeSize int AWSBatchChangeSizeBytes int AWSBatchChangeSizeValues int AWSBatchChangeInterval time.Duration AWSEvaluateTargetHealth bool AWSAPIRetries int AWSPreferCNAME bool AWSZoneCacheDuration time.Duration AWSSDServiceCleanup bool AWSSDCreateTag map[string]string AWSZoneMatchParent bool AWSDynamoDBRegion string AWSDynamoDBTable string AzureConfigFile string AzureResourceGroup string AzureSubscriptionID string AzureUserAssignedIdentityClientID string AzureActiveDirectoryAuthorityHost string AzureZonesCacheDuration time.Duration AzureMaxRetriesCount int BatchChangeSize int BatchChangeInterval time.Duration CloudflareProxied bool CloudflareCustomHostnames bool CloudflareDNSRecordsPerPage int CloudflareDNSRecordsComment string CloudflareCustomHostnamesMinTLSVersion string CloudflareCustomHostnamesCertificateAuthority string CloudflareRegionalServices bool CloudflareRegionKey string CoreDNSPrefix string CoreDNSStrictlyOwned bool AkamaiServiceConsumerDomain string AkamaiClientToken string AkamaiClientSecret string AkamaiAccessToken string AkamaiEdgercPath string AkamaiEdgercSection string OCIConfigFile string OCICompartmentOCID string OCIAuthInstancePrincipal bool OCIZoneScope string OCIZoneCacheDuration time.Duration InMemoryZones []string OVHEndpoint string OVHApiRateLimit int OVHEnableCNAMERelative bool PDNSServer string PDNSServerID string PDNSAPIKey string `secure:"yes"` PDNSSkipTLSVerify bool TLSCA string TLSClientCert string TLSClientCertKey string Policy string Registry string TXTOwnerID string TXTOwnerOld string TXTPrefix string TXTSuffix string TXTEncryptEnabled bool TXTEncryptAESKey string `secure:"yes"` Interval time.Duration MinEventSyncInterval time.Duration MinTTL time.Duration Once bool DryRun bool UpdateEvents bool LogFormat string MetricsAddress string LogLevel string TXTCacheInterval time.Duration TXTWildcardReplacement string ExoscaleEndpoint string ExoscaleAPIKey string `secure:"yes"` ExoscaleAPISecret string `secure:"yes"` ExoscaleAPIEnvironment string ExoscaleAPIZone string CRDSourceAPIVersion string CRDSourceKind string ServiceTypeFilter []string ResolveServiceLoadBalancerHostname bool RFC2136Host []string RFC2136Port int RFC2136Zone []string RFC2136Insecure bool RFC2136GSSTSIG bool RFC2136CreatePTR bool RFC2136KerberosRealm string RFC2136KerberosUsername string RFC2136KerberosPassword string `secure:"yes"` RFC2136TSIGKeyName string RFC2136TSIGSecret string `secure:"yes"` RFC2136TSIGSecretAlg string RFC2136TAXFR bool RFC2136MinTTL time.Duration RFC2136LoadBalancingStrategy string RFC2136BatchChangeSize int RFC2136UseTLS bool RFC2136SkipTLSVerify bool NS1Endpoint string NS1IgnoreSSL bool NS1MinTTLSeconds int TransIPAccountName string TransIPPrivateKeyFile string ManagedDNSRecordTypes []string ExcludeDNSRecordTypes []string GoDaddyAPIKey string `secure:"yes"` GoDaddySecretKey string `secure:"yes"` GoDaddyTTL int64 GoDaddyOTE bool OCPRouterName string PiholeServer string PiholePassword string `secure:"yes"` PiholeTLSInsecureSkipVerify bool PiholeApiVersion string PluralCluster string PluralProvider string WebhookProviderURL string WebhookProviderReadTimeout time.Duration WebhookProviderWriteTimeout time.Duration WebhookServer bool TraefikEnableLegacy bool TraefikDisableNew bool NAT64Networks []string ExcludeUnschedulable bool EmitEvents []string ForceDefaultTargets bool UnstructuredResources []string PreferAlias bool } var defaultConfig = &Config{ AkamaiAccessToken: "", AkamaiClientSecret: "", AkamaiClientToken: "", AkamaiEdgercPath: "", AkamaiEdgercSection: "", AkamaiServiceConsumerDomain: "", AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", AnnotationFilter: "", AnnotationPrefix: annotations.DefaultAnnotationPrefix, APIServerURL: "", AWSAPIRetries: 3, AWSAssumeRole: "", AWSAssumeRoleExternalID: "", AWSBatchChangeInterval: time.Second, AWSBatchChangeSize: 1000, AWSBatchChangeSizeBytes: 32000, AWSBatchChangeSizeValues: 1000, AWSDynamoDBRegion: "", AWSDynamoDBTable: "external-dns", AWSEvaluateTargetHealth: true, AWSPreferCNAME: false, AWSSDCreateTag: map[string]string{}, AWSSDServiceCleanup: false, AWSZoneCacheDuration: 0 * time.Second, AWSZoneMatchParent: false, AWSZoneTagFilter: []string{}, AWSZoneType: "", AzureConfigFile: "/etc/kubernetes/azure.json", AzureResourceGroup: "", AzureSubscriptionID: "", AzureZonesCacheDuration: 0 * time.Second, AzureMaxRetriesCount: 3, BatchChangeSize: 200, BatchChangeInterval: time.Second, CloudflareCustomHostnamesCertificateAuthority: "none", CloudflareCustomHostnames: false, CloudflareCustomHostnamesMinTLSVersion: "1.0", CloudflareDNSRecordsPerPage: 100, CloudflareProxied: false, CloudflareRegionalServices: false, CloudflareRegionKey: "earth", CombineFQDNAndAnnotation: false, Compatibility: "", ConnectorSourceServer: "localhost:8080", CoreDNSPrefix: "/skydns/", CoreDNSStrictlyOwned: false, CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", CRDSourceKind: "DNSEndpoint", DefaultTargets: []string{}, DomainFilter: []string{}, DryRun: false, ExcludeDNSRecordTypes: []string{}, DomainExclude: []string{}, ExcludeTargetNets: []string{}, EmitEvents: []string{}, ExcludeUnschedulable: true, ExoscaleAPIEnvironment: "api", ExoscaleAPIKey: "", ExoscaleAPISecret: "", ExoscaleAPIZone: "ch-gva-2", ExposeInternalIPV6: false, FQDNTemplate: "", TargetTemplate: "", FQDNTargetTemplate: "", GatewayLabelFilter: "", GatewayName: "", GatewayNamespace: "", GlooNamespaces: []string{"gloo-system"}, GoDaddyAPIKey: "", GoDaddyOTE: false, GoDaddySecretKey: "", GoDaddyTTL: 600, GoogleBatchChangeInterval: time.Second, GoogleBatchChangeSize: 1000, GoogleProject: "", GoogleZoneVisibility: "", IgnoreHostnameAnnotation: false, IgnoreIngressRulesSpec: false, IgnoreIngressTLSSpec: false, IngressClassNames: nil, InMemoryZones: []string{}, Interval: time.Minute, KubeConfig: "", LabelFilter: labels.Everything().String(), LogFormat: "text", LogLevel: logrus.InfoLevel.String(), ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, MetricsAddress: ":7979", MinEventSyncInterval: 5 * time.Second, MinTTL: 0, Namespace: "", NAT64Networks: []string{}, NS1Endpoint: "", NS1IgnoreSSL: false, OCIConfigFile: "/etc/kubernetes/oci.yaml", OCIZoneCacheDuration: 0 * time.Second, OCIZoneScope: "GLOBAL", Once: false, OVHApiRateLimit: 20, OVHEnableCNAMERelative: false, OVHEndpoint: "ovh-eu", PDNSAPIKey: "", PDNSServer: "http://localhost:8081", PDNSServerID: "localhost", PDNSSkipTLSVerify: false, PiholeApiVersion: "5", PiholePassword: "", PiholeServer: "", PiholeTLSInsecureSkipVerify: false, PluralCluster: "", PluralProvider: "", PodSourceDomain: "", Policy: "sync", Provider: "", ProviderCacheTime: 0, PublishHostIP: false, PublishInternal: false, RegexDomainExclude: regexp.MustCompile(""), RegexDomainFilter: regexp.MustCompile(""), Registry: RegistryTXT, RequestTimeout: time.Second * 30, RFC2136BatchChangeSize: 50, RFC2136GSSTSIG: false, RFC2136Host: []string{""}, RFC2136Insecure: false, RFC2136KerberosPassword: "", RFC2136KerberosRealm: "", RFC2136KerberosUsername: "", RFC2136LoadBalancingStrategy: "disabled", RFC2136MinTTL: 0, RFC2136Port: 0, RFC2136SkipTLSVerify: false, RFC2136TAXFR: true, RFC2136TSIGKeyName: "", RFC2136TSIGSecret: "", RFC2136TSIGSecretAlg: "", RFC2136UseTLS: false, RFC2136Zone: []string{}, ServiceTypeFilter: []string{}, SkipperRouteGroupVersion: "zalando.org/v1", Sources: nil, TargetNetFilter: []string{}, TLSCA: "", TLSClientCert: "", TLSClientCertKey: "", TraefikEnableLegacy: false, TraefikDisableNew: false, TransIPAccountName: "", TransIPPrivateKeyFile: "", TXTCacheInterval: 0, TXTEncryptAESKey: "", TXTEncryptEnabled: false, TXTOwnerID: "default", TXTOwnerOld: "", TXTPrefix: "", TXTSuffix: "", TXTWildcardReplacement: "", UpdateEvents: false, WebhookProviderReadTimeout: 5 * time.Second, WebhookProviderURL: "http://localhost:8888", WebhookProviderWriteTimeout: 10 * time.Second, WebhookServer: false, ZoneIDFilter: []string{}, ForceDefaultTargets: false, UnstructuredResources: []string{}, PreferAlias: false, } var ProviderNames = []string{ ProviderAkamai, ProviderAlibabaCloud, ProviderAWS, ProviderAWSSD, ProviderAzure, ProviderAzureDNS, ProviderAzurePrivate, ProviderCivo, ProviderCloudflare, ProviderCoreDNS, ProviderDNSimple, ProviderExoscale, ProviderGandi, ProviderGoDaddy, ProviderGoogle, ProviderInMemory, ProviderLinode, ProviderNS1, ProviderOCI, ProviderOVH, ProviderPDNS, ProviderPihole, ProviderPlural, ProviderRFC2136, ProviderScaleway, ProviderSkyDNS, ProviderTransip, ProviderWebhook, } var allowedSources = []string{ "service", "ingress", "node", "pod", "gateway-httproute", "gateway-grpcroute", "gateway-tlsroute", "gateway-tcproute", "gateway-udproute", "istio-gateway", "istio-virtualservice", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host", "kong-tcpingress", "f5-virtualserver", "f5-transportserver", "traefik-proxy", "unstructured", } // NewConfig returns new Config object func NewConfig() *Config { return &Config{ AnnotationPrefix: annotations.DefaultAnnotationPrefix, AWSSDCreateTag: map[string]string{}, } } func (cfg *Config) String() string { // prevent logging of sensitive information temp := *cfg t := reflect.TypeFor[Config]() for i := 0; i < t.NumField(); i++ { f := t.Field(i) if val, ok := f.Tag.Lookup("secure"); ok && val == "yes" { if f.Type.Kind() != reflect.String { continue } v := reflect.ValueOf(&temp).Elem().Field(i) if v.String() != "" { v.SetString(passwordMask) } } } return fmt.Sprintf("%+v", temp) } // allLogLevelsAsStrings returns all logrus levels as a list of strings func allLogLevelsAsStrings() []string { var levels []string for _, level := range logrus.AllLevels { levels = append(levels, level.String()) } return levels } // ParseFlags adds and parses flags from command line func (cfg *Config) ParseFlags(args []string) error { if _, err := App(cfg).Parse(args); err != nil { return err } return nil } func bindFlags(b flags.FlagBinder, cfg *Config) { // Flags related to Kubernetes b.StringVar("server", "The Kubernetes API server to connect to (default: auto-detect)", defaultConfig.APIServerURL, &cfg.APIServerURL) b.StringVar("kubeconfig", "Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect)", defaultConfig.KubeConfig, &cfg.KubeConfig) b.DurationVar("request-timeout", "Request timeout when calling Kubernetes APIs. 0s means no timeout", defaultConfig.RequestTimeout, &cfg.RequestTimeout) b.BoolVar("resolve-service-load-balancer-hostname", "Resolve the hostname of LoadBalancer-type Service object to IP addresses in order to create DNS A/AAAA records instead of CNAMEs", false, &cfg.ResolveServiceLoadBalancerHostname) b.BoolVar("listen-endpoint-events", "Trigger a reconcile on changes to EndpointSlices, for Service source (default: false)", false, &cfg.ListenEndpointEvents) // Flags related to Gloo b.StringsVar("gloo-namespace", "The Gloo Proxy namespace; specify multiple times for multiple namespaces. (default: gloo-system)", []string{"gloo-system"}, &cfg.GlooNamespaces) // Flags related to Skipper RouteGroup b.StringVar("skipper-routegroup-groupversion", "The resource version for skipper routegroup", defaultConfig.SkipperRouteGroupVersion, &cfg.SkipperRouteGroupVersion) // Flags related to processing source b.BoolVar("always-publish-not-ready-addresses", "Always publish also not ready addresses for headless services (optional)", false, &cfg.AlwaysPublishNotReadyAddresses) b.StringVar("annotation-filter", "Filter resources queried for endpoints by annotation, using label selector semantics", defaultConfig.AnnotationFilter, &cfg.AnnotationFilter) b.StringVar("annotation-prefix", "Annotation prefix for external-dns annotations (default: external-dns.alpha.kubernetes.io/)", defaultConfig.AnnotationPrefix, &cfg.AnnotationPrefix) b.EnumVar("compatibility", "Process annotation semantics from legacy implementations (optional, options: mate, molecule, kops-dns-controller)", defaultConfig.Compatibility, &cfg.Compatibility, "", "mate", "molecule", "kops-dns-controller") b.StringVar("connector-source-server", "The server to connect for connector source, valid only when using connector source", defaultConfig.ConnectorSourceServer, &cfg.ConnectorSourceServer) b.StringVar("crd-source-apiversion", "API version of the CRD for crd source, e.g. `externaldns.k8s.io/v1alpha1`, valid only when using crd source", defaultConfig.CRDSourceAPIVersion, &cfg.CRDSourceAPIVersion) b.StringVar("crd-source-kind", "Kind of the CRD for the crd source in API group and version specified by crd-source-apiversion", defaultConfig.CRDSourceKind, &cfg.CRDSourceKind) b.StringsVar("default-targets", "Set globally default host/IP that will apply as a target instead of source addresses. Specify multiple times for multiple targets (optional)", nil, &cfg.DefaultTargets) b.BoolVar("force-default-targets", "Force the application of --default-targets, overriding any targets provided by the source (DEPRECATED: This reverts to (improved) legacy behavior which allows empty CRD targets for migration to new state)", defaultConfig.ForceDefaultTargets, &cfg.ForceDefaultTargets) b.BoolVar("prefer-alias", "When enabled, CNAME records will have the alias annotation set, signaling providers that support ALIAS records to use them instead of CNAMEs. Supported by: PowerDNS, AWS (with --aws-prefer-cname disabled)", defaultConfig.PreferAlias, &cfg.PreferAlias) b.StringsVar("exclude-record-types", "Record types to exclude from management; specify multiple times to exclude many; (optional)", nil, &cfg.ExcludeDNSRecordTypes) b.StringsVar("exclude-target-net", "Exclude target nets (optional)", nil, &cfg.ExcludeTargetNets) b.BoolVar("exclude-unschedulable", "Exclude nodes that are considered unschedulable (default: true)", defaultConfig.ExcludeUnschedulable, &cfg.ExcludeUnschedulable) b.BoolVar("expose-internal-ipv6", "When using the node source, expose internal IPv6 addresses (optional, default: false)", false, &cfg.ExposeInternalIPV6) b.StringVar("gateway-label-filter", "Filter Gateways of Route endpoints via label selector (default: all gateways)", defaultConfig.GatewayLabelFilter, &cfg.GatewayLabelFilter) b.StringVar("gateway-name", "Limit Gateways of Route endpoints to a specific name (default: all names)", defaultConfig.GatewayName, &cfg.GatewayName) b.StringVar("gateway-namespace", "Limit Gateways of Route endpoints to a specific namespace (default: all namespaces)", defaultConfig.GatewayNamespace, &cfg.GatewayNamespace) b.BoolVar("ignore-hostname-annotation", "Ignore hostname annotation when generating DNS names, valid only when --fqdn-template is set (default: false)", false, &cfg.IgnoreHostnameAnnotation) b.BoolVar("ignore-ingress-rules-spec", "Ignore the spec.rules section in Ingress resources (default: false)", false, &cfg.IgnoreIngressRulesSpec) b.BoolVar("ignore-ingress-tls-spec", "Ignore the spec.tls section in Ingress resources (default: false)", false, &cfg.IgnoreIngressTLSSpec) b.BoolVar("ignore-non-host-network-pods", "Ignore pods not running on host network when using pod source (default: false)", false, &cfg.IgnoreNonHostNetworkPods) b.StringsVar("ingress-class", "Require an Ingress to have this class name; specify multiple times to allow more than one class (optional; defaults to any class)", nil, &cfg.IngressClassNames) b.StringVar("label-filter", "Filter resources queried for endpoints by label selector; currently supported by source types crd, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, ingress, node, openshift-route, service and ambassador-host", defaultConfig.LabelFilter, &cfg.LabelFilter) managedRecordTypesHelp := fmt.Sprintf("Record types to manage; specify multiple times to include many; (default: %s) (supported records: A, AAAA, CNAME, NS, SRV, TXT)", strings.Join(defaultConfig.ManagedDNSRecordTypes, ",")) b.StringsVar("managed-record-types", managedRecordTypesHelp, defaultConfig.ManagedDNSRecordTypes, &cfg.ManagedDNSRecordTypes) b.StringVar("namespace", "Limit resources queried for endpoints to a specific namespace (default: all namespaces)", defaultConfig.Namespace, &cfg.Namespace) b.StringsVar("nat64-networks", "Adding an A record for each AAAA record in NAT64-enabled networks; specify multiple times for multiple possible nets (optional)", nil, &cfg.NAT64Networks) b.StringVar("openshift-router-name", "if source is openshift-route then you can pass the ingress controller name. Based on this name external-dns will select the respective router from the route status and map that routerCanonicalHostname to the route host while creating a CNAME record.", defaultConfig.OCPRouterName, &cfg.OCPRouterName) b.StringVar("pod-source-domain", "Domain to use for pods records (optional)", defaultConfig.PodSourceDomain, &cfg.PodSourceDomain) b.BoolVar("publish-host-ip", "Allow external-dns to publish host-ip for headless services (optional)", false, &cfg.PublishHostIP) b.BoolVar("publish-internal-services", "Allow external-dns to publish DNS records for ClusterIP services (optional)", false, &cfg.PublishInternal) b.StringsVar("service-type-filter", "The service types to filter by. Specify multiple times for multiple filters to be applied. (optional, default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName)", defaultConfig.ServiceTypeFilter, &cfg.ServiceTypeFilter) b.StringsVar("target-net-filter", "Limit possible targets by a net filter; specify multiple times for multiple possible nets (optional)", nil, &cfg.TargetNetFilter) b.BoolVar("traefik-enable-legacy", "Enable legacy listeners on Resources under the traefik.containo.us API Group", defaultConfig.TraefikEnableLegacy, &cfg.TraefikEnableLegacy) b.BoolVar("traefik-disable-new", "Disable listeners on Resources under the traefik.io API Group", defaultConfig.TraefikDisableNew, &cfg.TraefikDisableNew) b.StringsVar("unstructured-resource", "When using the unstructured source, specify resources in resource.version.group format (e.g., virtualmachineinstances.v1.kubevirt.io, configmap.v1); specify multiple times for multiple resources", nil, &cfg.UnstructuredResources) b.StringsVar("events-emit", "Events that should be emitted. Specify multiple times for multiple events support (optional, default: none, expected: RecordReady, RecordDeleted, RecordError)", defaultConfig.EmitEvents, &cfg.EmitEvents) b.DurationVar("provider-cache-time", "The time to cache the DNS provider record list requests.", defaultConfig.ProviderCacheTime, &cfg.ProviderCacheTime) b.StringsVar("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)", []string{""}, &cfg.DomainFilter) b.StringsVar("exclude-domains", "Exclude subdomains (optional)", []string{""}, &cfg.DomainExclude) b.RegexpVar("regex-domain-filter", "Limit possible domains and target zones by a Regex filter; Overrides domain-filter (optional)", defaultConfig.RegexDomainFilter, &cfg.RegexDomainFilter) b.RegexpVar("regex-domain-exclusion", "Regex filter that excludes domains and target zones matched by regex-domain-filter (optional)", defaultConfig.RegexDomainExclude, &cfg.RegexDomainExclude) b.StringsVar("zone-name-filter", "Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)", []string{""}, &cfg.ZoneNameFilter) b.StringsVar("zone-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)", []string{""}, &cfg.ZoneIDFilter) b.StringVar("google-project", "When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.", defaultConfig.GoogleProject, &cfg.GoogleProject) b.IntVar("google-batch-change-size", "When using the Google provider, set the maximum number of changes that will be applied in each batch.", defaultConfig.GoogleBatchChangeSize, &cfg.GoogleBatchChangeSize) b.DurationVar("google-batch-change-interval", "When using the Google provider, set the interval between batch changes.", defaultConfig.GoogleBatchChangeInterval, &cfg.GoogleBatchChangeInterval) b.EnumVar("google-zone-visibility", "When using the Google provider, filter for zones with this visibility (optional, options: public, private)", defaultConfig.GoogleZoneVisibility, &cfg.GoogleZoneVisibility, "", "public", "private") b.StringVar("alibaba-cloud-config-file", "When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud)", defaultConfig.AlibabaCloudConfigFile, &cfg.AlibabaCloudConfigFile) b.EnumVar("alibaba-cloud-zone-type", "When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private)", defaultConfig.AlibabaCloudZoneType, &cfg.AlibabaCloudZoneType, "", "public", "private") b.EnumVar("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, default: any, options: public, private)", defaultConfig.AWSZoneType, &cfg.AWSZoneType, "", "public", "private") b.StringsVar("aws-zone-tags", "When using the AWS provider, filter for zones with these tags", []string{""}, &cfg.AWSZoneTagFilter) b.StringsVar("aws-profile", "When using the AWS provider, name of the profile to use", []string{""}, &cfg.AWSProfiles) b.StringVar("aws-assume-role", "When using the AWS API, assume this IAM role. Useful for hosted zones in another AWS account. Specify the full ARN, e.g. `arn:aws:iam::123455567:role/external-dns` (optional)", defaultConfig.AWSAssumeRole, &cfg.AWSAssumeRole) b.StringVar("aws-assume-role-external-id", "When using the AWS API and assuming a role then specify this external ID` (optional)", defaultConfig.AWSAssumeRoleExternalID, &cfg.AWSAssumeRoleExternalID) b.IntVar("aws-batch-change-size", "When using the AWS provider, set the maximum number of changes that will be applied in each batch.", defaultConfig.AWSBatchChangeSize, &cfg.AWSBatchChangeSize) b.IntVar("aws-batch-change-size-bytes", "When using the AWS provider, set the maximum byte size that will be applied in each batch.", defaultConfig.AWSBatchChangeSizeBytes, &cfg.AWSBatchChangeSizeBytes) b.IntVar("aws-batch-change-size-values", "When using the AWS provider, set the maximum total record values that will be applied in each batch.", defaultConfig.AWSBatchChangeSizeValues, &cfg.AWSBatchChangeSizeValues) b.DurationVar("aws-batch-change-interval", "When using the AWS provider, set the interval between batch changes.", defaultConfig.AWSBatchChangeInterval, &cfg.AWSBatchChangeInterval) b.BoolVar("aws-evaluate-target-health", "When using the AWS provider, set whether to evaluate the health of a DNS target (default: enabled, disable with --no-aws-evaluate-target-health)", defaultConfig.AWSEvaluateTargetHealth, &cfg.AWSEvaluateTargetHealth) b.IntVar("aws-api-retries", "When using the AWS API, set the maximum number of retries before giving up.", defaultConfig.AWSAPIRetries, &cfg.AWSAPIRetries) b.BoolVar("aws-prefer-cname", "When using the AWS provider, prefer using CNAME instead of ALIAS (default: disabled)", defaultConfig.AWSPreferCNAME, &cfg.AWSPreferCNAME) b.DurationVar("aws-zones-cache-duration", "When using the AWS provider, set the zones list cache TTL (0s to disable).", defaultConfig.AWSZoneCacheDuration, &cfg.AWSZoneCacheDuration) b.BoolVar("aws-zone-match-parent", "Expand limit possible target by sub-domains (default: disabled)", defaultConfig.AWSZoneMatchParent, &cfg.AWSZoneMatchParent) b.BoolVar("aws-sd-service-cleanup", "When using the AWS CloudMap provider, delete empty Services without endpoints (default: disabled)", defaultConfig.AWSSDServiceCleanup, &cfg.AWSSDServiceCleanup) b.StringMapVar("aws-sd-create-tag", "When using the AWS CloudMap provider, add tag to created services. The flag can be used multiple times", &cfg.AWSSDCreateTag) b.StringVar("azure-config-file", "When using the Azure provider, specify the Azure configuration file (required when --provider=azure)", defaultConfig.AzureConfigFile, &cfg.AzureConfigFile) b.StringVar("azure-resource-group", "When using the Azure provider, override the Azure resource group to use (optional)", defaultConfig.AzureResourceGroup, &cfg.AzureResourceGroup) b.StringVar("azure-subscription-id", "When using the Azure provider, override the Azure subscription to use (optional)", defaultConfig.AzureSubscriptionID, &cfg.AzureSubscriptionID) b.StringVar("azure-user-assigned-identity-client-id", "When using the Azure provider, override the client id of user assigned identity in config file (optional)", "", &cfg.AzureUserAssignedIdentityClientID) b.DurationVar("azure-zones-cache-duration", "When using the Azure provider, set the zones list cache TTL (0s to disable).", defaultConfig.AzureZonesCacheDuration, &cfg.AzureZonesCacheDuration) b.IntVar("azure-maxretries-count", "When using the Azure provider, set the number of retries for API calls (When less than 0, it disables retries). (optional)", defaultConfig.AzureMaxRetriesCount, &cfg.AzureMaxRetriesCount) b.IntVar("batch-change-size", "Set the maximum number of DNS record changes that will be submitted to the provider in each batch (optional)", defaultConfig.BatchChangeSize, &cfg.BatchChangeSize) b.DurationVar("batch-change-interval", "Set the interval between batch changes (optional, default: 1s)", defaultConfig.BatchChangeInterval, &cfg.BatchChangeInterval) b.BoolVar("cloudflare-proxied", "When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)", false, &cfg.CloudflareProxied) b.BoolVar("cloudflare-custom-hostnames", "When using the Cloudflare provider, specify if the Custom Hostnames feature will be used. Requires \"Cloudflare for SaaS\" enabled. (default: disabled)", false, &cfg.CloudflareCustomHostnames) b.EnumVar("cloudflare-custom-hostnames-min-tls-version", "When using the Cloudflare provider with the Custom Hostnames, specify which Minimum TLS Version will be used by default. (default: 1.0, options: 1.0, 1.1, 1.2, 1.3)", "1.0", &cfg.CloudflareCustomHostnamesMinTLSVersion, "1.0", "1.1", "1.2", "1.3") b.EnumVar("cloudflare-custom-hostnames-certificate-authority", "When using the Cloudflare provider with the Custom Hostnames, specify which Certificate Authority will be used. A value of none indicates no Certificate Authority will be sent to the Cloudflare API (default: none, options: google, ssl_com, lets_encrypt, none)", "none", &cfg.CloudflareCustomHostnamesCertificateAuthority, "google", "ssl_com", "lets_encrypt", "none") b.IntVar("cloudflare-dns-records-per-page", "When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100)", defaultConfig.CloudflareDNSRecordsPerPage, &cfg.CloudflareDNSRecordsPerPage) b.BoolVar("cloudflare-regional-services", "When using the Cloudflare provider, specify if Regional Services feature will be used (default: disabled)", defaultConfig.CloudflareRegionalServices, &cfg.CloudflareRegionalServices) b.StringVar("cloudflare-region-key", "When using the Cloudflare provider, specify the default region for Regional Services. Any value other than an empty string will enable the Regional Services feature (optional)", "", &cfg.CloudflareRegionKey) b.StringVar("cloudflare-record-comment", "When using the Cloudflare provider, specify the comment for the DNS records (default: '')", "", &cfg.CloudflareDNSRecordsComment) b.StringVar("coredns-prefix", "When using the CoreDNS provider, specify the prefix name", defaultConfig.CoreDNSPrefix, &cfg.CoreDNSPrefix) b.BoolVar("coredns-strictly-owned", "When using the CoreDNS provider, store and filter strictly by txt-owner-id using an extra field inside of the etcd service (default: false)", defaultConfig.CoreDNSStrictlyOwned, &cfg.CoreDNSStrictlyOwned) b.StringVar("akamai-serviceconsumerdomain", "When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified)", defaultConfig.AkamaiServiceConsumerDomain, &cfg.AkamaiServiceConsumerDomain) b.StringVar("akamai-client-token", "When using the Akamai provider, specify the client token (required when --provider=akamai and edgerc-path not specified)", defaultConfig.AkamaiClientToken, &cfg.AkamaiClientToken) b.StringVar("akamai-client-secret", "When using the Akamai provider, specify the client secret (required when --provider=akamai and edgerc-path not specified)", defaultConfig.AkamaiClientSecret, &cfg.AkamaiClientSecret) b.StringVar("akamai-access-token", "When using the Akamai provider, specify the access token (required when --provider=akamai and edgerc-path not specified)", defaultConfig.AkamaiAccessToken, &cfg.AkamaiAccessToken) b.StringVar("akamai-edgerc-path", "When using the Akamai provider, specify the .edgerc file path. Path must be reachable form invocation environment. (required when --provider=akamai and *-token, secret serviceconsumerdomain not specified)", defaultConfig.AkamaiEdgercPath, &cfg.AkamaiEdgercPath) b.StringVar("akamai-edgerc-section", "When using the Akamai provider, specify the .edgerc file path (Optional when edgerc-path is specified)", defaultConfig.AkamaiEdgercSection, &cfg.AkamaiEdgercSection) b.StringVar("oci-config-file", "When using the OCI provider, specify the OCI configuration file (required when --provider=oci", defaultConfig.OCIConfigFile, &cfg.OCIConfigFile) b.StringVar("oci-compartment-ocid", "When using the OCI provider, specify the OCID of the OCI compartment containing all managed zones and records. Required when using OCI IAM instance principal authentication.", defaultConfig.OCICompartmentOCID, &cfg.OCICompartmentOCID) b.EnumVar("oci-zone-scope", "When using OCI provider, filter for zones with this scope (optional, options: GLOBAL, PRIVATE). Defaults to GLOBAL, setting to empty value will target both.", defaultConfig.OCIZoneScope, &cfg.OCIZoneScope, "", "GLOBAL", "PRIVATE") b.BoolVar("oci-auth-instance-principal", "When using the OCI provider, specify whether OCI IAM instance principal authentication should be used (instead of key-based auth via the OCI config file).", defaultConfig.OCIAuthInstancePrincipal, &cfg.OCIAuthInstancePrincipal) b.DurationVar("oci-zones-cache-duration", "When using the OCI provider, set the zones list cache TTL (0s to disable).", defaultConfig.OCIZoneCacheDuration, &cfg.OCIZoneCacheDuration) b.StringsVar("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)", []string{""}, &cfg.InMemoryZones) b.StringVar("ovh-endpoint", "When using the OVH provider, specify the endpoint (default: ovh-eu)", defaultConfig.OVHEndpoint, &cfg.OVHEndpoint) b.IntVar("ovh-api-rate-limit", "When using the OVH provider, specify the API request rate limit, X operations by seconds (default: 20)", defaultConfig.OVHApiRateLimit, &cfg.OVHApiRateLimit) b.BoolVar("ovh-enable-cname-relative", "When using the OVH provider, specify if CNAME should be treated as relative on target without final dot (default: false)", defaultConfig.OVHEnableCNAMERelative, &cfg.OVHEnableCNAMERelative) b.StringVar("pdns-server", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)", defaultConfig.PDNSServer, &cfg.PDNSServer) b.StringVar("pdns-server-id", "When using the PowerDNS/PDNS provider, specify the id of the server to retrieve. Should be `localhost` except when the server is behind a proxy (optional when --provider=pdns) (default: localhost)", defaultConfig.PDNSServerID, &cfg.PDNSServerID) b.StringVar("pdns-api-key", "When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns)", defaultConfig.PDNSAPIKey, &cfg.PDNSAPIKey) b.BoolVar("pdns-skip-tls-verify", "When using the PowerDNS/PDNS provider, disable verification of any TLS certificates (optional when --provider=pdns) (default: false)", defaultConfig.PDNSSkipTLSVerify, &cfg.PDNSSkipTLSVerify) b.StringVar("ns1-endpoint", "When using the NS1 provider, specify the URL of the API endpoint to target (default: https://api.nsone.net/v1/)", defaultConfig.NS1Endpoint, &cfg.NS1Endpoint) b.BoolVar("ns1-ignoressl", "When using the NS1 provider, specify whether to verify the SSL certificate (default: false)", defaultConfig.NS1IgnoreSSL, &cfg.NS1IgnoreSSL) b.IntVar("ns1-min-ttl", "Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this.", cfg.NS1MinTTLSeconds, &cfg.NS1MinTTLSeconds) // GoDaddy flags b.StringVar("godaddy-api-key", "When using the GoDaddy provider, specify the API Key (required when --provider=godaddy)", defaultConfig.GoDaddyAPIKey, &cfg.GoDaddyAPIKey) b.StringVar("godaddy-api-secret", "When using the GoDaddy provider, specify the API secret (required when --provider=godaddy)", defaultConfig.GoDaddySecretKey, &cfg.GoDaddySecretKey) b.Int64Var("godaddy-api-ttl", "TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is not provided.", cfg.GoDaddyTTL, &cfg.GoDaddyTTL) b.BoolVar("godaddy-api-ote", "When using the GoDaddy provider, use OTE api (optional, default: false, when --provider=godaddy)", defaultConfig.GoDaddyOTE, &cfg.GoDaddyOTE) // Flags related to TLS communication b.StringVar("tls-ca", "When using TLS communication, the path to the certificate authority to verify server communications (optionally specify --tls-client-cert for two-way TLS)", defaultConfig.TLSCA, &cfg.TLSCA) b.StringVar("tls-client-cert", "When using TLS communication, the path to the certificate to present as a client (not required for TLS)", defaultConfig.TLSClientCert, &cfg.TLSClientCert) b.StringVar("tls-client-cert-key", "When using TLS communication, the path to the certificate key to use with the client certificate (not required for TLS)", defaultConfig.TLSClientCertKey, &cfg.TLSClientCertKey) // Flags related to Exoscale provider b.StringVar("exoscale-apienv", "When using Exoscale provider, specify the API environment (optional)", defaultConfig.ExoscaleAPIEnvironment, &cfg.ExoscaleAPIEnvironment) b.StringVar("exoscale-apizone", "When using Exoscale provider, specify the API Zone (optional)", defaultConfig.ExoscaleAPIZone, &cfg.ExoscaleAPIZone) b.StringVar("exoscale-apikey", "Provide your API Key for the Exoscale provider", defaultConfig.ExoscaleAPIKey, &cfg.ExoscaleAPIKey) b.StringVar("exoscale-apisecret", "Provide your API Secret for the Exoscale provider", defaultConfig.ExoscaleAPISecret, &cfg.ExoscaleAPISecret) // Flags related to RFC2136 provider b.StringsVar("rfc2136-host", "When using the RFC2136 provider, specify the host of the DNS server (optionally specify multiple times when using --rfc2136-load-balancing-strategy)", []string{defaultConfig.RFC2136Host[0]}, &cfg.RFC2136Host) b.IntVar("rfc2136-port", "When using the RFC2136 provider, specify the port of the DNS server", defaultConfig.RFC2136Port, &cfg.RFC2136Port) b.StringsVar("rfc2136-zone", "When using the RFC2136 provider, specify zone entry of the DNS server to use (can be specified multiple times)", nil, &cfg.RFC2136Zone) b.BoolVar("rfc2136-create-ptr", "When using the RFC2136 provider, enable PTR management", defaultConfig.RFC2136CreatePTR, &cfg.RFC2136CreatePTR) b.BoolVar("rfc2136-insecure", "When using the RFC2136 provider, specify whether to attach TSIG or not (default: false, requires --rfc2136-tsig-keyname and rfc2136-tsig-secret)", defaultConfig.RFC2136Insecure, &cfg.RFC2136Insecure) b.StringVar("rfc2136-tsig-keyname", "When using the RFC2136 provider, specify the TSIG key to attached to DNS messages (required when --rfc2136-insecure=false)", defaultConfig.RFC2136TSIGKeyName, &cfg.RFC2136TSIGKeyName) b.StringVar("rfc2136-tsig-secret", "When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)", defaultConfig.RFC2136TSIGSecret, &cfg.RFC2136TSIGSecret) b.StringVar("rfc2136-tsig-secret-alg", "When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)", defaultConfig.RFC2136TSIGSecretAlg, &cfg.RFC2136TSIGSecretAlg) b.BoolVar("rfc2136-tsig-axfr", "When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)", false, &cfg.RFC2136TAXFR) b.DurationVar("rfc2136-min-ttl", "When using the RFC2136 provider, specify minimal TTL (in duration format) for records. This value will be used if the provided TTL for a service/ingress is lower than this", defaultConfig.RFC2136MinTTL, &cfg.RFC2136MinTTL) b.BoolVar("rfc2136-gss-tsig", "When using the RFC2136 provider, specify whether to use secure updates with GSS-TSIG using Kerberos (default: false, requires --rfc2136-kerberos-realm, --rfc2136-kerberos-username, and rfc2136-kerberos-password)", defaultConfig.RFC2136GSSTSIG, &cfg.RFC2136GSSTSIG) b.StringVar("rfc2136-kerberos-username", "When using the RFC2136 provider with GSS-TSIG, specify the username of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)", defaultConfig.RFC2136KerberosUsername, &cfg.RFC2136KerberosUsername) b.StringVar("rfc2136-kerberos-password", "When using the RFC2136 provider with GSS-TSIG, specify the password of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)", defaultConfig.RFC2136KerberosPassword, &cfg.RFC2136KerberosPassword) b.StringVar("rfc2136-kerberos-realm", "When using the RFC2136 provider with GSS-TSIG, specify the realm of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)", defaultConfig.RFC2136KerberosRealm, &cfg.RFC2136KerberosRealm) b.IntVar("rfc2136-batch-change-size", "When using the RFC2136 provider, set the maximum number of changes that will be applied in each batch.", defaultConfig.RFC2136BatchChangeSize, &cfg.RFC2136BatchChangeSize) b.BoolVar("rfc2136-use-tls", "When using the RFC2136 provider, communicate with name server over tls", defaultConfig.RFC2136UseTLS, &cfg.RFC2136UseTLS) b.BoolVar("rfc2136-skip-tls-verify", "When using TLS with the RFC2136 provider, disable verification of any TLS certificates", defaultConfig.RFC2136SkipTLSVerify, &cfg.RFC2136SkipTLSVerify) b.EnumVar("rfc2136-load-balancing-strategy", "When using the RFC2136 provider, specify the load balancing strategy (default: disabled, options: random, round-robin, disabled)", defaultConfig.RFC2136LoadBalancingStrategy, &cfg.RFC2136LoadBalancingStrategy, "random", "round-robin", "disabled") // Flags related to TransIP provider b.StringVar("transip-account", "When using the TransIP provider, specify the account name (required when --provider=transip)", defaultConfig.TransIPAccountName, &cfg.TransIPAccountName) b.StringVar("transip-keyfile", "When using the TransIP provider, specify the path to the private key file (required when --provider=transip)", defaultConfig.TransIPPrivateKeyFile, &cfg.TransIPPrivateKeyFile) // Flags related to Pihole provider b.StringVar("pihole-server", "When using the Pihole provider, the base URL of the Pihole web server (required when --provider=pihole)", defaultConfig.PiholeServer, &cfg.PiholeServer) b.StringVar("pihole-password", "When using the Pihole provider, the password to the server if it is protected", defaultConfig.PiholePassword, &cfg.PiholePassword) b.BoolVar("pihole-tls-skip-verify", "When using the Pihole provider, disable verification of any TLS certificates", defaultConfig.PiholeTLSInsecureSkipVerify, &cfg.PiholeTLSInsecureSkipVerify) b.StringVar("pihole-api-version", "When using the Pihole provider, specify the pihole API version (default: 5, options: 5, 6)", defaultConfig.PiholeApiVersion, &cfg.PiholeApiVersion) // Flags related to the Plural provider b.StringVar("plural-cluster", "When using the plural provider, specify the cluster name you're running with", defaultConfig.PluralCluster, &cfg.PluralCluster) b.StringVar("plural-provider", "When using the plural provider, specify the provider name you're running with", defaultConfig.PluralProvider, &cfg.PluralProvider) // Flags related to policies b.EnumVar("policy", "Modify how DNS records are synchronized between sources and providers (default: sync, options: sync, upsert-only, create-only)", defaultConfig.Policy, &cfg.Policy, "sync", "upsert-only", "create-only") // Flags related to the registry b.EnumVar("registry", "The registry implementation to use to keep track of DNS record ownership (default: txt, options: txt, noop, dynamodb, aws-sd)", defaultConfig.Registry, &cfg.Registry, RegistryTXT, RegistryNoop, RegistryDynamoDB, RegistryAWSSD) b.StringVar("txt-owner-id", "When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default)", defaultConfig.TXTOwnerID, &cfg.TXTOwnerID) b.StringVar("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!", defaultConfig.TXTPrefix, &cfg.TXTPrefix) b.StringVar("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!", defaultConfig.TXTSuffix, &cfg.TXTSuffix) b.StringVar("txt-wildcard-replacement", "When using the TXT registry, a custom string that's used instead of an asterisk for TXT records corresponding to wildcard DNS records (optional)", defaultConfig.TXTWildcardReplacement, &cfg.TXTWildcardReplacement) b.BoolVar("txt-encrypt-enabled", "When using the TXT registry, set if TXT records should be encrypted before stored (default: disabled)", defaultConfig.TXTEncryptEnabled, &cfg.TXTEncryptEnabled) b.StringVar("txt-encrypt-aes-key", "When using the TXT registry, set TXT record decryption and encryption 32 byte aes key (required when --txt-encrypt=true)", defaultConfig.TXTEncryptAESKey, &cfg.TXTEncryptAESKey) b.StringVar("migrate-from-txt-owner", "Old txt-owner-id that needs to be overwritten (default: default)", defaultConfig.TXTOwnerOld, &cfg.TXTOwnerOld) b.StringVar("dynamodb-region", "When using the DynamoDB registry, the AWS region of the DynamoDB table (optional)", cfg.AWSDynamoDBRegion, &cfg.AWSDynamoDBRegion) b.StringVar("dynamodb-table", "When using the DynamoDB registry, the name of the DynamoDB table (default: \"external-dns\")", defaultConfig.AWSDynamoDBTable, &cfg.AWSDynamoDBTable) // Flags related to the main control loop b.DurationVar("txt-cache-interval", "The interval between cache synchronizations in duration format (default: disabled)", defaultConfig.TXTCacheInterval, &cfg.TXTCacheInterval) b.DurationVar("interval", "The interval between two consecutive synchronizations in duration format (default: 1m)", defaultConfig.Interval, &cfg.Interval) b.DurationVar("min-event-sync-interval", "The minimum interval between two consecutive synchronizations triggered from kubernetes events in duration format (default: 5s)", defaultConfig.MinEventSyncInterval, &cfg.MinEventSyncInterval) b.BoolVar("once", "When enabled, exits the synchronization loop after the first iteration (default: disabled)", defaultConfig.Once, &cfg.Once) b.BoolVar("dry-run", "When enabled, prints DNS record changes rather than actually performing them (default: disabled)", defaultConfig.DryRun, &cfg.DryRun) b.BoolVar("events", "When enabled, in addition to running every interval, the reconciliation loop will get triggered when supported sources change (default: disabled)", defaultConfig.UpdateEvents, &cfg.UpdateEvents) b.DurationVar("min-ttl", "Configure global TTL for records in duration format. This value is used when the TTL for a source is not set or set to 0. (optional; examples: 1m12s, 72s, 72)", defaultConfig.MinTTL, &cfg.MinTTL) // Miscellaneous flags b.EnumVar("log-format", "The format in which log messages are printed (default: text, options: text, json)", defaultConfig.LogFormat, &cfg.LogFormat, "text", "json") b.StringVar("metrics-address", "Specify where to serve the metrics and health check endpoint (default: :7979)", defaultConfig.MetricsAddress, &cfg.MetricsAddress) b.EnumVar("log-level", "Set the level of logging. (default: info, options: panic, debug, info, warning, error, fatal)", defaultConfig.LogLevel, &cfg.LogLevel, allLogLevelsAsStrings()...) // Webhook provider b.StringVar("webhook-provider-url", "The URL of the remote endpoint to call for the webhook provider (default: http://localhost:8888)", defaultConfig.WebhookProviderURL, &cfg.WebhookProviderURL) b.DurationVar("webhook-provider-read-timeout", "The read timeout for the webhook provider in duration format (default: 5s)", defaultConfig.WebhookProviderReadTimeout, &cfg.WebhookProviderReadTimeout) b.DurationVar("webhook-provider-write-timeout", "The write timeout for the webhook provider in duration format (default: 10s)", defaultConfig.WebhookProviderWriteTimeout, &cfg.WebhookProviderWriteTimeout) b.BoolVar("webhook-server", "When enabled, runs as a webhook server instead of a controller. (default: false).", defaultConfig.WebhookServer, &cfg.WebhookServer) // FQDN Templating b.BoolVar("combine-fqdn-annotation", "Combine FQDN template and Annotations instead of overwriting (default: false)", false, &cfg.CombineFQDNAndAnnotation) b.StringVar("fqdn-template", "A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN.", defaultConfig.FQDNTemplate, &cfg.FQDNTemplate) b.StringVar("target-template", "A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Accepts comma separated list for multiple targets.", defaultConfig.TargetTemplate, &cfg.TargetTemplate) b.StringVar("fqdn-target-template", "A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Accepts comma separated list for multiple pairs.", defaultConfig.FQDNTargetTemplate, &cfg.FQDNTargetTemplate) } func App(cfg *Config) *kingpin.Application { app := kingpin.New("external-dns", "ExternalDNS synchronizes exposed Kubernetes Services and Ingresses with DNS providers.\n\nNote that all flags may be replaced with env vars - `--flag` -> `EXTERNAL_DNS_FLAG=1` or `--flag value` -> `EXTERNAL_DNS_FLAG=value`") app.Version(Version) app.DefaultEnvars() bindFlags(flags.NewKingpinBinder(app), cfg) // Kingpin-only semantics: preserve Required/PlaceHolder and enum validation // that Kingpin provided before the flags were migrated into the binder. providerHelp := "The DNS provider where the DNS records will be created (required, options: " + strings.Join(ProviderNames, ", ") + ")" app.Flag("provider", providerHelp).Required().PlaceHolder("provider").EnumVar(&cfg.Provider, ProviderNames...) // Reintroduce source enum/required validation in Kingpin to match previous behavior. sourceHelp := "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: " + strings.Join(allowedSources, ", ") + ")" app.Flag("source", sourceHelp).Required().PlaceHolder("source").EnumsVar(&cfg.Sources, allowedSources...) return app } ================================================ FILE: pkg/apis/externaldns/types_test.go ================================================ /* Copyright 2017 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 externaldns import ( "regexp" "testing" "time" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/flags" "sigs.k8s.io/external-dns/internal/testutils" "github.com/alecthomas/kingpin/v2" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var ( minimalConfig = &Config{ APIServerURL: "", KubeConfig: "", RequestTimeout: time.Second * 30, GlooNamespaces: []string{"gloo-system"}, SkipperRouteGroupVersion: "zalando.org/v1", Sources: []string{"service"}, Namespace: "", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", FQDNTemplate: "", Compatibility: "", Provider: ProviderGoogle, GoogleProject: "", GoogleBatchChangeSize: 1000, GoogleBatchChangeInterval: time.Second, GoogleZoneVisibility: "", DomainFilter: []string{""}, DomainExclude: []string{""}, RegexDomainFilter: regexp.MustCompile(""), RegexDomainExclude: regexp.MustCompile(""), ZoneNameFilter: []string{""}, ZoneIDFilter: []string{""}, AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", AWSZoneType: "", AWSZoneTagFilter: []string{""}, AWSZoneMatchParent: false, AWSAssumeRole: "", AWSAssumeRoleExternalID: "", AWSBatchChangeSize: 1000, AWSBatchChangeSizeBytes: 32000, AWSBatchChangeSizeValues: 1000, AWSBatchChangeInterval: time.Second, AWSEvaluateTargetHealth: true, AWSAPIRetries: 3, AWSPreferCNAME: false, AWSProfiles: []string{""}, AWSZoneCacheDuration: 0 * time.Second, AWSSDServiceCleanup: false, AWSSDCreateTag: map[string]string{}, AWSDynamoDBTable: "external-dns", AzureConfigFile: "/etc/kubernetes/azure.json", AzureResourceGroup: "", AzureSubscriptionID: "", AzureMaxRetriesCount: 3, BatchChangeSize: 200, BatchChangeInterval: time.Second, CloudflareProxied: false, CloudflareCustomHostnames: false, CloudflareCustomHostnamesMinTLSVersion: "1.0", CloudflareCustomHostnamesCertificateAuthority: "none", CloudflareDNSRecordsPerPage: 100, CloudflareDNSRecordsComment: "", CloudflareRegionKey: "", CoreDNSPrefix: "/skydns/", AkamaiServiceConsumerDomain: "", AkamaiClientToken: "", AkamaiClientSecret: "", AkamaiAccessToken: "", AkamaiEdgercPath: "", AkamaiEdgercSection: "", OCIConfigFile: "/etc/kubernetes/oci.yaml", OCIZoneScope: "GLOBAL", OCIZoneCacheDuration: 0 * time.Second, InMemoryZones: []string{""}, OVHEndpoint: "ovh-eu", OVHApiRateLimit: 20, PDNSServer: "http://localhost:8081", PDNSServerID: "localhost", PDNSAPIKey: "", Policy: "sync", Registry: "txt", TXTOwnerID: "default", TXTOwnerOld: "", TXTPrefix: "", TXTCacheInterval: 0, Interval: time.Minute, MinEventSyncInterval: 5 * time.Second, Once: false, DryRun: false, UpdateEvents: false, LogFormat: "text", MetricsAddress: ":7979", LogLevel: logrus.InfoLevel.String(), ConnectorSourceServer: "localhost:8080", ExoscaleAPIEnvironment: "api", ExoscaleAPIZone: "ch-gva-2", ExoscaleAPIKey: "", ExoscaleAPISecret: "", CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", CRDSourceKind: "DNSEndpoint", TransIPAccountName: "", TransIPPrivateKeyFile: "", ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, RFC2136BatchChangeSize: 50, RFC2136Host: []string{""}, RFC2136LoadBalancingStrategy: "disabled", OCPRouterName: "default", PiholeApiVersion: "5", WebhookProviderURL: "http://localhost:8888", WebhookProviderReadTimeout: 5 * time.Second, WebhookProviderWriteTimeout: 10 * time.Second, ExcludeUnschedulable: true, } overriddenConfig = &Config{ APIServerURL: "http://127.0.0.1:8080", KubeConfig: "/some/path", RequestTimeout: time.Second * 77, GlooNamespaces: []string{"gloo-not-system", "gloo-second-system"}, SkipperRouteGroupVersion: "zalando.org/v2", Sources: []string{"service", "ingress", "connector"}, Namespace: "namespace", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", IgnoreHostnameAnnotation: true, IgnoreNonHostNetworkPods: true, IgnoreIngressTLSSpec: true, IgnoreIngressRulesSpec: true, FQDNTemplate: "{{.Name}}.service.example.com", Compatibility: "mate", Provider: ProviderGoogle, GoogleProject: "project", GoogleBatchChangeSize: 100, GoogleBatchChangeInterval: time.Second * 2, GoogleZoneVisibility: "private", DomainFilter: []string{"example.org", "company.com"}, DomainExclude: []string{"xapi.example.org", "xapi.company.com"}, RegexDomainFilter: regexp.MustCompile("(example\\.org|company\\.com)$"), RegexDomainExclude: regexp.MustCompile("xapi\\.(example\\.org|company\\.com)$"), ZoneNameFilter: []string{"yapi.example.org", "yapi.company.com"}, ZoneIDFilter: []string{"/hostedzone/ZTST1", "/hostedzone/ZTST2"}, TargetNetFilter: []string{"10.0.0.0/9", "10.1.0.0/9"}, ExcludeTargetNets: []string{"1.0.0.0/9", "1.1.0.0/9"}, AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", AWSZoneType: "private", AWSZoneTagFilter: []string{"tag=foo"}, AWSZoneMatchParent: true, AWSAssumeRole: "some-other-role", AWSAssumeRoleExternalID: "pg2000", AWSBatchChangeSize: 100, AWSBatchChangeSizeBytes: 16000, AWSBatchChangeSizeValues: 100, AWSBatchChangeInterval: time.Second * 2, AWSEvaluateTargetHealth: false, AWSAPIRetries: 13, AWSPreferCNAME: true, AWSProfiles: []string{"profile1", "profile2"}, AWSZoneCacheDuration: 10 * time.Second, AWSSDServiceCleanup: true, AWSSDCreateTag: map[string]string{"key1": "value1", "key2": "value2"}, AWSDynamoDBTable: "custom-table", AzureConfigFile: "azure.json", AzureResourceGroup: "arg", AzureSubscriptionID: "arg", AzureMaxRetriesCount: 4, BatchChangeSize: 200, BatchChangeInterval: time.Second, CloudflareProxied: true, CloudflareCustomHostnames: true, CloudflareCustomHostnamesMinTLSVersion: "1.3", CloudflareCustomHostnamesCertificateAuthority: "google", CloudflareDNSRecordsPerPage: 5000, CloudflareRegionalServices: true, CloudflareRegionKey: "us", CoreDNSPrefix: "/coredns/", AkamaiServiceConsumerDomain: "oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", AkamaiClientToken: "o184671d5307a388180fbf7f11dbdf46", AkamaiClientSecret: "o184671d5307a388180fbf7f11dbdf46", AkamaiAccessToken: "o184671d5307a388180fbf7f11dbdf46", AkamaiEdgercPath: "/home/test/.edgerc", AkamaiEdgercSection: "default", OCIConfigFile: "oci.yaml", OCIZoneScope: "PRIVATE", OCIZoneCacheDuration: 30 * time.Second, InMemoryZones: []string{"example.org", "company.com"}, OVHEndpoint: "ovh-ca", OVHApiRateLimit: 42, PDNSServer: "http://ns.example.com:8081", PDNSServerID: "localhost", PDNSAPIKey: "some-secret-key", PDNSSkipTLSVerify: true, TLSCA: "/path/to/ca.crt", TLSClientCert: "/path/to/cert.pem", TLSClientCertKey: "/path/to/key.pem", PodSourceDomain: "example.org", Policy: "upsert-only", Registry: "noop", TXTOwnerID: "owner-1", TXTPrefix: "associated-txt-record", TXTOwnerOld: "old-owner", TXTCacheInterval: 12 * time.Hour, Interval: 10 * time.Minute, MinEventSyncInterval: 50 * time.Second, MinTTL: 40 * time.Second, Once: true, DryRun: true, UpdateEvents: true, LogFormat: "json", MetricsAddress: "127.0.0.1:9099", LogLevel: logrus.DebugLevel.String(), ConnectorSourceServer: "localhost:8081", ExoscaleAPIEnvironment: "api1", ExoscaleAPIZone: "zone1", ExoscaleAPIKey: "1", ExoscaleAPISecret: "2", CRDSourceAPIVersion: "test.k8s.io/v1alpha1", CRDSourceKind: "Endpoint", NS1Endpoint: "https://api.example.com/v1", NS1IgnoreSSL: true, TransIPAccountName: "transip", TransIPPrivateKeyFile: "/path/to/transip.key", ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS}, RFC2136BatchChangeSize: 100, RFC2136Host: []string{"rfc2136-host1", "rfc2136-host2"}, RFC2136LoadBalancingStrategy: "round-robin", PiholeApiVersion: "6", WebhookProviderURL: "http://localhost:8888", WebhookProviderReadTimeout: 5 * time.Second, WebhookProviderWriteTimeout: 10 * time.Second, ExcludeUnschedulable: false, } ) func TestParseFlags(t *testing.T) { for _, ti := range []struct { title string args []string envVars map[string]string expected func(*Config) }{ { title: "default config with minimal flags defined", args: []string{ "--source=service", "--provider=google", "--openshift-router-name=default", }, envVars: map[string]string{}, expected: func(cfg *Config) { assert.Equal(t, minimalConfig, cfg) }, }, { title: "validate bool flags work as expected", args: []string{ "--source=service", "--provider=google", "--aws-evaluate-target-health", "--exclude-unschedulable", }, envVars: map[string]string{}, expected: func(cfg *Config) { assert.True(t, cfg.AWSEvaluateTargetHealth) assert.True(t, cfg.ExcludeUnschedulable) }, }, { title: "validate negation flags work as expected", args: []string{ "--source=service", "--provider=aws", "--no-aws-evaluate-target-health", "--no-exclude-unschedulable", }, envVars: map[string]string{}, expected: func(cfg *Config) { assert.False(t, cfg.AWSEvaluateTargetHealth) assert.False(t, cfg.ExcludeUnschedulable) }, }, { title: "override everything via flags", args: []string{ "--server=http://127.0.0.1:8080", "--kubeconfig=/some/path", "--request-timeout=77s", "--gloo-namespace=gloo-not-system", "--gloo-namespace=gloo-second-system", "--skipper-routegroup-groupversion=zalando.org/v2", "--source=service", "--source=ingress", "--source=connector", "--namespace=namespace", "--fqdn-template={{.Name}}.service.example.com", "--ignore-non-host-network-pods", "--ignore-hostname-annotation", "--ignore-ingress-tls-spec", "--ignore-ingress-rules-spec", "--compatibility=mate", "--provider=google", "--google-project=project", "--google-batch-change-size=100", "--google-batch-change-interval=2s", "--google-zone-visibility=private", "--azure-config-file=azure.json", "--azure-resource-group=arg", "--azure-subscription-id=arg", "--azure-maxretries-count=4", "--cloudflare-proxied", "--cloudflare-custom-hostnames", "--cloudflare-custom-hostnames-min-tls-version=1.3", "--cloudflare-custom-hostnames-certificate-authority=google", "--cloudflare-dns-records-per-page=5000", "--cloudflare-regional-services", "--cloudflare-region-key=us", "--coredns-prefix=/coredns/", "--akamai-serviceconsumerdomain=oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", "--akamai-client-token=o184671d5307a388180fbf7f11dbdf46", "--akamai-client-secret=o184671d5307a388180fbf7f11dbdf46", "--akamai-access-token=o184671d5307a388180fbf7f11dbdf46", "--akamai-edgerc-path=/home/test/.edgerc", "--akamai-edgerc-section=default", "--inmemory-zone=example.org", "--inmemory-zone=company.com", "--ovh-endpoint=ovh-ca", "--ovh-api-rate-limit=42", "--pdns-server=http://ns.example.com:8081", "--pdns-server-id=localhost", "--pdns-api-key=some-secret-key", "--pdns-skip-tls-verify", "--oci-config-file=oci.yaml", "--oci-zone-scope=PRIVATE", "--oci-zones-cache-duration=30s", "--tls-ca=/path/to/ca.crt", "--tls-client-cert=/path/to/cert.pem", "--tls-client-cert-key=/path/to/key.pem", "--pod-source-domain=example.org", "--domain-filter=example.org", "--domain-filter=company.com", "--exclude-domains=xapi.example.org", "--exclude-domains=xapi.company.com", "--regex-domain-filter=(example\\.org|company\\.com)$", "--regex-domain-exclusion=xapi\\.(example\\.org|company\\.com)$", "--zone-name-filter=yapi.example.org", "--zone-name-filter=yapi.company.com", "--zone-id-filter=/hostedzone/ZTST1", "--zone-id-filter=/hostedzone/ZTST2", "--target-net-filter=10.0.0.0/9", "--target-net-filter=10.1.0.0/9", "--exclude-target-net=1.0.0.0/9", "--exclude-target-net=1.1.0.0/9", "--aws-zone-type=private", "--aws-zone-tags=tag=foo", "--aws-zone-match-parent", "--aws-assume-role=some-other-role", "--aws-assume-role-external-id=pg2000", "--aws-batch-change-size=100", "--aws-batch-change-size-bytes=16000", "--aws-batch-change-size-values=100", "--aws-batch-change-interval=2s", "--aws-api-retries=13", "--aws-prefer-cname", "--aws-profile=profile1", "--aws-profile=profile2", "--aws-zones-cache-duration=10s", "--aws-sd-service-cleanup", "--aws-sd-create-tag=key1=value1", "--aws-sd-create-tag=key2=value2", "--no-aws-evaluate-target-health", "--pihole-api-version=6", "--policy=upsert-only", "--registry=noop", "--txt-owner-id=owner-1", "--migrate-from-txt-owner=old-owner", "--txt-prefix=associated-txt-record", "--txt-cache-interval=12h", "--dynamodb-table=custom-table", "--interval=10m", "--min-event-sync-interval=50s", "--min-ttl=40s", "--once", "--dry-run", "--events", "--log-format=json", "--metrics-address=127.0.0.1:9099", "--log-level=debug", "--connector-source-server=localhost:8081", "--exoscale-apienv=api1", "--exoscale-apizone=zone1", "--exoscale-apikey=1", "--exoscale-apisecret=2", "--crd-source-apiversion=test.k8s.io/v1alpha1", "--crd-source-kind=Endpoint", "--ns1-endpoint=https://api.example.com/v1", "--ns1-ignoressl", "--transip-account=transip", "--transip-keyfile=/path/to/transip.key", "--managed-record-types=A", "--managed-record-types=AAAA", "--managed-record-types=CNAME", "--managed-record-types=NS", "--no-exclude-unschedulable", "--rfc2136-batch-change-size=100", "--rfc2136-load-balancing-strategy=round-robin", "--rfc2136-host=rfc2136-host1", "--rfc2136-host=rfc2136-host2", "--batch-change-size=200", }, envVars: map[string]string{}, expected: func(cfg *Config) { assert.Equal(t, overriddenConfig, cfg) }, }, { title: "override everything via environment variables", args: []string{}, envVars: map[string]string{ "EXTERNAL_DNS_SERVER": "http://127.0.0.1:8080", "EXTERNAL_DNS_KUBECONFIG": "/some/path", "EXTERNAL_DNS_REQUEST_TIMEOUT": "77s", "EXTERNAL_DNS_CONTOUR_LOAD_BALANCER": "heptio-contour-other/contour-other", "EXTERNAL_DNS_GLOO_NAMESPACE": "gloo-not-system\ngloo-second-system", "EXTERNAL_DNS_SKIPPER_ROUTEGROUP_GROUPVERSION": "zalando.org/v2", "EXTERNAL_DNS_SOURCE": "service\ningress\nconnector", "EXTERNAL_DNS_NAMESPACE": "namespace", "EXTERNAL_DNS_FQDN_TEMPLATE": "{{.Name}}.service.example.com", "EXTERNAL_DNS_IGNORE_NON_HOST_NETWORK_PODS": "1", "EXTERNAL_DNS_IGNORE_HOSTNAME_ANNOTATION": "1", "EXTERNAL_DNS_IGNORE_INGRESS_TLS_SPEC": "1", "EXTERNAL_DNS_IGNORE_INGRESS_RULES_SPEC": "1", "EXTERNAL_DNS_COMPATIBILITY": "mate", "EXTERNAL_DNS_PROVIDER": "google", "EXTERNAL_DNS_GOOGLE_PROJECT": "project", "EXTERNAL_DNS_GOOGLE_BATCH_CHANGE_SIZE": "100", "EXTERNAL_DNS_GOOGLE_BATCH_CHANGE_INTERVAL": "2s", "EXTERNAL_DNS_GOOGLE_ZONE_VISIBILITY": "private", "EXTERNAL_DNS_AZURE_CONFIG_FILE": "azure.json", "EXTERNAL_DNS_AZURE_RESOURCE_GROUP": "arg", "EXTERNAL_DNS_AZURE_SUBSCRIPTION_ID": "arg", "EXTERNAL_DNS_AZURE_MAXRETRIES_COUNT": "4", "EXTERNAL_DNS_CLOUDFLARE_PROXIED": "1", "EXTERNAL_DNS_CLOUDFLARE_CUSTOM_HOSTNAMES": "1", "EXTERNAL_DNS_CLOUDFLARE_CUSTOM_HOSTNAMES_MIN_TLS_VERSION": "1.3", "EXTERNAL_DNS_CLOUDFLARE_CUSTOM_HOSTNAMES_CERTIFICATE_AUTHORITY": "google", "EXTERNAL_DNS_CLOUDFLARE_DNS_RECORDS_PER_PAGE": "5000", "EXTERNAL_DNS_CLOUDFLARE_REGIONAL_SERVICES": "1", "EXTERNAL_DNS_CLOUDFLARE_REGION_KEY": "us", "EXTERNAL_DNS_COREDNS_PREFIX": "/coredns/", "EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN": "oooo-xxxxxxxxxxxxxxxx-xxxxxxxxxxxxxxxx.luna.akamaiapis.net", "EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN": "o184671d5307a388180fbf7f11dbdf46", "EXTERNAL_DNS_AKAMAI_CLIENT_SECRET": "o184671d5307a388180fbf7f11dbdf46", "EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN": "o184671d5307a388180fbf7f11dbdf46", "EXTERNAL_DNS_AKAMAI_EDGERC_PATH": "/home/test/.edgerc", "EXTERNAL_DNS_AKAMAI_EDGERC_SECTION": "default", "EXTERNAL_DNS_OCI_CONFIG_FILE": "oci.yaml", "EXTERNAL_DNS_OCI_ZONE_SCOPE": "PRIVATE", "EXTERNAL_DNS_OCI_ZONES_CACHE_DURATION": "30s", "EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com", "EXTERNAL_DNS_OVH_ENDPOINT": "ovh-ca", "EXTERNAL_DNS_OVH_API_RATE_LIMIT": "42", "EXTERNAL_DNS_POD_SOURCE_DOMAIN": "example.org", "EXTERNAL_DNS_DOMAIN_FILTER": "example.org\ncompany.com", "EXTERNAL_DNS_EXCLUDE_DOMAINS": "xapi.example.org\nxapi.company.com", "EXTERNAL_DNS_REGEX_DOMAIN_FILTER": "(example\\.org|company\\.com)$", "EXTERNAL_DNS_REGEX_DOMAIN_EXCLUSION": "xapi\\.(example\\.org|company\\.com)$", "EXTERNAL_DNS_TARGET_NET_FILTER": "10.0.0.0/9\n10.1.0.0/9", "EXTERNAL_DNS_EXCLUDE_TARGET_NET": "1.0.0.0/9\n1.1.0.0/9", "EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081", "EXTERNAL_DNS_PDNS_ID": "localhost", "EXTERNAL_DNS_PDNS_API_KEY": "some-secret-key", "EXTERNAL_DNS_PDNS_SKIP_TLS_VERIFY": "1", "EXTERNAL_DNS_RDNS_ROOT_DOMAIN": "lb.rancher.cloud", "EXTERNAL_DNS_TLS_CA": "/path/to/ca.crt", "EXTERNAL_DNS_TLS_CLIENT_CERT": "/path/to/cert.pem", "EXTERNAL_DNS_TLS_CLIENT_CERT_KEY": "/path/to/key.pem", "EXTERNAL_DNS_ZONE_NAME_FILTER": "yapi.example.org\nyapi.company.com", "EXTERNAL_DNS_ZONE_ID_FILTER": "/hostedzone/ZTST1\n/hostedzone/ZTST2", "EXTERNAL_DNS_AWS_ZONE_TYPE": "private", "EXTERNAL_DNS_AWS_ZONE_TAGS": "tag=foo", "EXTERNAL_DNS_AWS_ZONE_MATCH_PARENT": "true", "EXTERNAL_DNS_AWS_ASSUME_ROLE": "some-other-role", "EXTERNAL_DNS_AWS_ASSUME_ROLE_EXTERNAL_ID": "pg2000", "EXTERNAL_DNS_AWS_BATCH_CHANGE_SIZE": "100", "EXTERNAL_DNS_AWS_BATCH_CHANGE_SIZE_BYTES": "16000", "EXTERNAL_DNS_AWS_BATCH_CHANGE_SIZE_VALUES": "100", "EXTERNAL_DNS_AWS_BATCH_CHANGE_INTERVAL": "2s", "EXTERNAL_DNS_AWS_EVALUATE_TARGET_HEALTH": "0", "EXTERNAL_DNS_AWS_API_RETRIES": "13", "EXTERNAL_DNS_AWS_PREFER_CNAME": "true", "EXTERNAL_DNS_AWS_PROFILE": "profile1\nprofile2", "EXTERNAL_DNS_AWS_ZONES_CACHE_DURATION": "10s", "EXTERNAL_DNS_AWS_SD_SERVICE_CLEANUP": "true", "EXTERNAL_DNS_AWS_SD_CREATE_TAG": "key1=value1\nkey2=value2", "EXTERNAL_DNS_DYNAMODB_TABLE": "custom-table", "EXTERNAL_DNS_PIHOLE_API_VERSION": "6", "EXTERNAL_DNS_POLICY": "upsert-only", "EXTERNAL_DNS_REGISTRY": "noop", "EXTERNAL_DNS_TXT_OWNER_ID": "owner-1", "EXTERNAL_DNS_TXT_PREFIX": "associated-txt-record", "EXTERNAL_DNS_MIGRATE_FROM_TXT_OWNER": "old-owner", "EXTERNAL_DNS_TXT_CACHE_INTERVAL": "12h", "EXTERNAL_DNS_TXT_NEW_FORMAT_ONLY": "1", "EXTERNAL_DNS_INTERVAL": "10m", "EXTERNAL_DNS_MIN_EVENT_SYNC_INTERVAL": "50s", "EXTERNAL_DNS_MIN_TTL": "40s", "EXTERNAL_DNS_ONCE": "1", "EXTERNAL_DNS_DRY_RUN": "1", "EXTERNAL_DNS_EVENTS": "1", "EXTERNAL_DNS_LOG_FORMAT": "json", "EXTERNAL_DNS_METRICS_ADDRESS": "127.0.0.1:9099", "EXTERNAL_DNS_LOG_LEVEL": "debug", "EXTERNAL_DNS_CONNECTOR_SOURCE_SERVER": "localhost:8081", "EXTERNAL_DNS_EXOSCALE_APIENV": "api1", "EXTERNAL_DNS_EXOSCALE_APIZONE": "zone1", "EXTERNAL_DNS_EXOSCALE_APIKEY": "1", "EXTERNAL_DNS_EXOSCALE_APISECRET": "2", "EXTERNAL_DNS_CRD_SOURCE_APIVERSION": "test.k8s.io/v1alpha1", "EXTERNAL_DNS_CRD_SOURCE_KIND": "Endpoint", "EXTERNAL_DNS_NS1_ENDPOINT": "https://api.example.com/v1", "EXTERNAL_DNS_NS1_IGNORESSL": "1", "EXTERNAL_DNS_TRANSIP_ACCOUNT": "transip", "EXTERNAL_DNS_TRANSIP_KEYFILE": "/path/to/transip.key", "EXTERNAL_DNS_MANAGED_RECORD_TYPES": "A\nAAAA\nCNAME\nNS", "EXTERNAL_DNS_EXCLUDE_UNSCHEDULABLE": "false", "EXTERNAL_DNS_RFC2136_BATCH_CHANGE_SIZE": "100", "EXTERNAL_DNS_RFC2136_LOAD_BALANCING_STRATEGY": "round-robin", "EXTERNAL_DNS_RFC2136_HOST": "rfc2136-host1\nrfc2136-host2", "EXTERNAL_DNS_BATCH_CHANGE_SIZE": "200", }, expected: func(cfg *Config) { assert.Equal(t, overriddenConfig, cfg) }, }, } { t.Run(ti.title, func(t *testing.T) { testutils.TestHelperEnvSetter(t, ti.envVars) cfg := NewConfig() require.NoError(t, cfg.ParseFlags(ti.args)) ti.expected(cfg) }) } } func TestParseFlagsCobraExecuteError(t *testing.T) { cfg := NewConfig() err := cfg.ParseFlags([]string{"--cli-backend=cobra", "--unknown-flag"}) require.Error(t, err) } func TestParseFlagsKingpinParseError(t *testing.T) { cfg := NewConfig() err := cfg.ParseFlags([]string{"--unknown-flag"}) require.Error(t, err) } func TestConfigStringMasksSecureFields(t *testing.T) { cfg := NewConfig() cfg.AWSAssumeRoleExternalID = "sensitive-value" cfg.GoDaddyAPIKey = "another-secret" s := cfg.String() require.NotContains(t, s, "sensitive-value") require.NotContains(t, s, "another-secret") require.Contains(t, s, passwordMask) } // Default path should use kingpin and parse flags correctly func TestParseFlagsDefaultKingpin(t *testing.T) { t.Setenv("EXTERNAL_DNS_CLI", "") args := []string{ "--provider=aws", "--source=service", "--source=ingress", "--server=http://127.0.0.1:8080", "--kubeconfig=/some/path", "--request-timeout=2s", "--namespace=ns", "--domain-filter=example.org", "--domain-filter=company.com", "--openshift-router-name=default", } cfg := NewConfig() require.NoError(t, cfg.ParseFlags(args)) assert.Equal(t, ProviderAWS, cfg.Provider) assert.ElementsMatch(t, []string{"service", "ingress"}, cfg.Sources) assert.Equal(t, "http://127.0.0.1:8080", cfg.APIServerURL) assert.Equal(t, "/some/path", cfg.KubeConfig) assert.Equal(t, 2*time.Second, cfg.RequestTimeout) assert.Equal(t, "ns", cfg.Namespace) assert.ElementsMatch(t, []string{"example.org", "company.com"}, cfg.DomainFilter) assert.Equal(t, "default", cfg.OCPRouterName) } // When EXTERNAL_DNS_CLI=cobra is set, cobra path should parse the subset of // flags it currently binds, yielding parity with kingpin for those fields. func TestParseFlagsCobraSwitchParitySubset(t *testing.T) { args := []string{ "--provider=aws", "--source=service", "--source=ingress", "--server=http://127.0.0.1:8080", "--kubeconfig=/some/path", "--request-timeout=2s", "--namespace=ns", "--domain-filter=example.org", "--domain-filter=company.com", "--openshift-router-name=default", } // Kingpin baseline cfgK := NewConfig() require.NoError(t, cfgK.ParseFlags(args)) // Cobra path via env switch t.Setenv("EXTERNAL_DNS_CLI", "cobra") cfgC := NewConfig() require.NoError(t, cfgC.ParseFlags(args)) // Compare selected fields bound in cobra assert.Equal(t, cfgK.Provider, cfgC.Provider) assert.ElementsMatch(t, cfgK.Sources, cfgC.Sources) assert.Equal(t, cfgK.APIServerURL, cfgC.APIServerURL) assert.Equal(t, cfgK.KubeConfig, cfgC.KubeConfig) assert.Equal(t, cfgK.RequestTimeout, cfgC.RequestTimeout) assert.Equal(t, cfgK.Namespace, cfgC.Namespace) assert.ElementsMatch(t, cfgK.DomainFilter, cfgC.DomainFilter) assert.Equal(t, cfgK.OCPRouterName, cfgC.OCPRouterName) } func TestParseFlagsCliFlagOverridesEnv(t *testing.T) { // Env requests cobra; CLI flag forces kingpin. t.Setenv("EXTERNAL_DNS_CLI", "cobra") args := []string{ "--provider=aws", "--source=service", // Flag not bound in Cobra newCobraCommand path; will error if cobra is used. "--log-format=json", } cfg := NewConfig() require.NoError(t, cfg.ParseFlags(args)) assert.Equal(t, ProviderAWS, cfg.Provider) assert.ElementsMatch(t, []string{"service"}, cfg.Sources) assert.Equal(t, "json", cfg.LogFormat) } func TestParseFlagsCliFlagSeparatedValue(t *testing.T) { // Support "--cli-backend", "cobra" form as well. args := []string{ "--provider=aws", "--source=service", } cfg := NewConfig() require.NoError(t, cfg.ParseFlags(args)) assert.Equal(t, ProviderAWS, cfg.Provider) assert.ElementsMatch(t, []string{"service"}, cfg.Sources) } func TestPasswordsNotLogged(t *testing.T) { cfg := Config{ PDNSAPIKey: "pdns-api-key", RFC2136TSIGSecret: "tsig-secret", } s := cfg.String() assert.NotContains(t, s, "pdns-api-key") assert.NotContains(t, s, "tsig-secret") } // Additional assertions to cover previously unasserted flags. These focus on // exercising Kingpin flag bindings for a wide set of providers/features. // parseCfg builds a Config by parsing base flags plus any extras. func parseCfg(t *testing.T, extra ...string) *Config { t.Helper() cfg := NewConfig() args := append([]string{"--provider=google", "--source=service"}, extra...) require.NoError(t, cfg.ParseFlags(args)) return cfg } func TestParseFlagsAlibabaCloud(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--alibaba-cloud-config-file=/etc/kubernetes/alibaba-override.json", "--alibaba-cloud-zone-type=private", ) assert.Equal(t, "/etc/kubernetes/alibaba-override.json", cfg.AlibabaCloudConfigFile) assert.Equal(t, "private", cfg.AlibabaCloudZoneType) } func TestParseFlagsPublishingAndFilters(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--always-publish-not-ready-addresses", "--annotation-filter=key=value", "--combine-fqdn-annotation", "--default-targets=1.2.3.4", "--default-targets=5.6.7.8", "--exclude-record-types=TXT", "--exclude-record-types=CNAME", "--expose-internal-ipv6", "--force-default-targets", "--ingress-class=nginx", "--ingress-class=internal", "--label-filter=environment=prod", "--nat64-networks=64:ff9b::/96", "--nat64-networks=64:ff9b:1::/48", "--publish-host-ip", "--publish-internal-services", "--resolve-service-load-balancer-hostname", "--service-type-filter=ClusterIP", "--service-type-filter=NodePort", "--events-emit=RecordReady", "--events-emit=RecordDeleted", ) assert.True(t, cfg.AlwaysPublishNotReadyAddresses) assert.Equal(t, "key=value", cfg.AnnotationFilter) assert.True(t, cfg.CombineFQDNAndAnnotation) assert.ElementsMatch(t, []string{"1.2.3.4", "5.6.7.8"}, cfg.DefaultTargets) assert.ElementsMatch(t, []string{"TXT", "CNAME"}, cfg.ExcludeDNSRecordTypes) assert.True(t, cfg.ExposeInternalIPV6) assert.True(t, cfg.ForceDefaultTargets) assert.ElementsMatch(t, []string{"nginx", "internal"}, cfg.IngressClassNames) assert.Equal(t, "environment=prod", cfg.LabelFilter) assert.ElementsMatch(t, []string{"64:ff9b::/96", "64:ff9b:1::/48"}, cfg.NAT64Networks) assert.True(t, cfg.PublishHostIP) assert.True(t, cfg.PublishInternal) assert.True(t, cfg.ResolveServiceLoadBalancerHostname) assert.ElementsMatch(t, []string{"ClusterIP", "NodePort"}, cfg.ServiceTypeFilter) assert.ElementsMatch(t, []string{"RecordReady", "RecordDeleted"}, cfg.EmitEvents) } func TestParseFlagsGateway(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--gateway-label-filter=app=gateway", "--gateway-name=gw-1", "--gateway-namespace=gw-ns", ) assert.Equal(t, "app=gateway", cfg.GatewayLabelFilter) assert.Equal(t, "gw-1", cfg.GatewayName) assert.Equal(t, "gw-ns", cfg.GatewayNamespace) } func TestParseFlagsAzure(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--azure-user-assigned-identity-client-id=00000000-0000-0000-0000-000000000000", "--azure-zones-cache-duration=30s", ) assert.Equal(t, "00000000-0000-0000-0000-000000000000", cfg.AzureUserAssignedIdentityClientID) assert.Equal(t, 30*time.Second, cfg.AzureZonesCacheDuration) } func TestParseFlagsCloudflare(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--cloudflare-record-comment=managed-by-external-dns") assert.Equal(t, "managed-by-external-dns", cfg.CloudflareDNSRecordsComment) } func TestParseFlagsNS1(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--ns1-min-ttl=60") assert.Equal(t, 60, cfg.NS1MinTTLSeconds) } func TestParseFlagsOVH(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--ovh-enable-cname-relative") assert.True(t, cfg.OVHEnableCNAMERelative) } func TestParseFlagsPihole(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--pihole-server=https://pi.example", "--pihole-password=pw", "--pihole-tls-skip-verify", ) assert.Equal(t, "https://pi.example", cfg.PiholeServer) assert.Equal(t, "pw", cfg.PiholePassword) assert.True(t, cfg.PiholeTLSInsecureSkipVerify) } func TestParseFlagsOCI(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--oci-auth-instance-principal", "--oci-compartment-ocid=ocid1.compartment.oc1..aaaa", ) assert.True(t, cfg.OCIAuthInstancePrincipal) assert.Equal(t, "ocid1.compartment.oc1..aaaa", cfg.OCICompartmentOCID) } func TestParseFlagsPlural(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--plural-cluster=mycluster", "--plural-provider=aws", ) assert.Equal(t, "mycluster", cfg.PluralCluster) assert.Equal(t, "aws", cfg.PluralProvider) } func TestParseFlagsProviderCacheAndDynamoDB(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--provider-cache-time=20s", "--dynamodb-region=us-east-2", ) assert.Equal(t, 20*time.Second, cfg.ProviderCacheTime) assert.Equal(t, "us-east-2", cfg.AWSDynamoDBRegion) } func TestParseFlagsGoDaddy(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--godaddy-api-key=key", "--godaddy-api-secret=secret", "--godaddy-api-ttl=1234", "--godaddy-api-ote", ) assert.Equal(t, "key", cfg.GoDaddyAPIKey) assert.Equal(t, "secret", cfg.GoDaddySecretKey) assert.Equal(t, int64(1234), cfg.GoDaddyTTL) assert.True(t, cfg.GoDaddyOTE) } func TestParseFlagsRFC2136(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--rfc2136-port=5353", "--rfc2136-zone=example.org.", "--rfc2136-zone=example.com.", "--rfc2136-create-ptr", "--rfc2136-insecure", "--rfc2136-kerberos-realm=EXAMPLE.COM", "--rfc2136-kerberos-username=svc-externaldns", "--rfc2136-kerberos-password=secret", "--rfc2136-tsig-keyname=keyname.", "--rfc2136-tsig-secret=base64secret", "--rfc2136-tsig-secret-alg=hmac-sha256", "--rfc2136-tsig-axfr", "--rfc2136-min-ttl=30s", "--rfc2136-gss-tsig", "--rfc2136-use-tls", "--rfc2136-skip-tls-verify", ) assert.Equal(t, 5353, cfg.RFC2136Port) assert.ElementsMatch(t, []string{"example.org.", "example.com."}, cfg.RFC2136Zone) assert.True(t, cfg.RFC2136CreatePTR) assert.True(t, cfg.RFC2136Insecure) assert.Equal(t, "EXAMPLE.COM", cfg.RFC2136KerberosRealm) assert.Equal(t, "svc-externaldns", cfg.RFC2136KerberosUsername) assert.Equal(t, "secret", cfg.RFC2136KerberosPassword) assert.Equal(t, "keyname.", cfg.RFC2136TSIGKeyName) assert.Equal(t, "base64secret", cfg.RFC2136TSIGSecret) assert.Equal(t, "hmac-sha256", cfg.RFC2136TSIGSecretAlg) assert.True(t, cfg.RFC2136TAXFR) assert.Equal(t, 30*time.Second, cfg.RFC2136MinTTL) assert.True(t, cfg.RFC2136GSSTSIG) assert.True(t, cfg.RFC2136UseTLS) assert.True(t, cfg.RFC2136SkipTLSVerify) } func TestParseFlagsTraefik(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--traefik-enable-legacy", "--traefik-disable-new", ) assert.True(t, cfg.TraefikEnableLegacy) assert.True(t, cfg.TraefikDisableNew) } func TestParseFlagsTXTRegistry(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--txt-encrypt-enabled", "--txt-encrypt-aes-key=0123456789abcdef0123456789abcdef", "--txt-suffix=-suffix", "--txt-wildcard-replacement=X", ) assert.True(t, cfg.TXTEncryptEnabled) assert.Equal(t, "0123456789abcdef0123456789abcdef", cfg.TXTEncryptAESKey) assert.Equal(t, "-suffix", cfg.TXTSuffix) assert.Equal(t, "X", cfg.TXTWildcardReplacement) } func TestParseFlagsWebhookProvider(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--webhook-provider-url=http://127.0.0.1:9999", "--webhook-provider-read-timeout=7s", "--webhook-provider-write-timeout=8s", "--webhook-server", ) assert.Equal(t, "http://127.0.0.1:9999", cfg.WebhookProviderURL) assert.Equal(t, 7*time.Second, cfg.WebhookProviderReadTimeout) assert.Equal(t, 8*time.Second, cfg.WebhookProviderWriteTimeout) assert.True(t, cfg.WebhookServer) } func TestParseFlagsMiscListeners(t *testing.T) { t.Parallel() cfg := parseCfg(t, "--listen-endpoint-events") assert.True(t, cfg.ListenEndpointEvents) } // Helpers to run bindFlags + parse for each binder. func runWithKingpin(t *testing.T, args []string) *Config { t.Helper() cfg := &Config{} cfg.AWSSDCreateTag = map[string]string{} cfg.RegexDomainFilter = defaultConfig.RegexDomainFilter app := kingpin.New("test", "") bindFlags(flags.NewKingpinBinder(app), cfg) _, err := app.Parse(args) require.NoError(t, err) return cfg } func TestBinderParityRepeatable(t *testing.T) { args := []string{"--managed-record-types=A", "--managed-record-types=TXT"} cfgK := runWithKingpin(t, args) assert.ElementsMatch(t, []string{"A", "TXT"}, cfgK.ManagedDNSRecordTypes) } func TestBinderParityMapAndRegexp(t *testing.T) { args := []string{"--regex-domain-filter=^ex.*$", "--aws-sd-create-tag=foo=bar"} cfgK := runWithKingpin(t, args) require.NotNil(t, cfgK.RegexDomainFilter) require.NotNil(t, cfgK.AWSSDCreateTag) assert.Equal(t, map[string]string{"foo": "bar"}, cfgK.AWSSDCreateTag) } // Kingpin validates enum values at parse time func TestBinderEnumValidationDifference(t *testing.T) { // Kingpin should reject unknown enum values appArgs := []string{"--google-zone-visibility=bogus"} app := kingpin.New("test", "") cfgK := &Config{} bindFlags(flags.NewKingpinBinder(app), cfgK) _, err := app.Parse(appArgs) require.Error(t, err) } ================================================ FILE: pkg/apis/externaldns/validation/validation.go ================================================ /* Copyright 2017 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 validation import ( "errors" "fmt" "strings" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) // ValidateConfig performs validation on the Config object func ValidateConfig(cfg *externaldns.Config) error { // TODO: Should probably return field.ErrorList if err := preValidateConfig(cfg); err != nil { return err } if err := validateConfigForProvider(cfg); err != nil { return err } if cfg.IgnoreHostnameAnnotation && cfg.FQDNTemplate == "" { return errors.New("FQDN Template must be set if ignoring annotations") } if len(cfg.TXTPrefix) > 0 && len(cfg.TXTSuffix) > 0 { return errors.New("txt-prefix and txt-suffix are mutual exclusive") } _, err := labels.Parse(cfg.LabelFilter) if err != nil { return errors.New("--label-filter does not specify a valid label selector") } if cfg.AnnotationPrefix == "" { return errors.New("--annotation-prefix cannot be empty") } if !strings.HasSuffix(cfg.AnnotationPrefix, "/") { return errors.New("--annotation-prefix must end with '/'") } return nil } func preValidateConfig(cfg *externaldns.Config) error { if cfg.LogFormat != "text" && cfg.LogFormat != "json" { return fmt.Errorf("unsupported log format: %s", cfg.LogFormat) } if len(cfg.Sources) == 0 { return errors.New("no sources specified") } if cfg.Provider == "" { return errors.New("no provider specified") } return nil } func validateConfigForProvider(cfg *externaldns.Config) error { switch cfg.Provider { case externaldns.ProviderAzure: return validateConfigForAzure(cfg) case externaldns.ProviderAkamai: return validateConfigForAkamai(cfg) case externaldns.ProviderRFC2136: return validateConfigForRfc2136(cfg) default: return nil } } func validateConfigForAzure(cfg *externaldns.Config) error { if cfg.AzureConfigFile == "" { return errors.New("no Azure config file specified") } return nil } func validateConfigForAkamai(cfg *externaldns.Config) error { if cfg.AkamaiServiceConsumerDomain == "" && cfg.AkamaiEdgercPath != "" { return errors.New("no Akamai ServiceConsumerDomain specified") } if cfg.AkamaiClientToken == "" && cfg.AkamaiEdgercPath != "" { return errors.New("no Akamai client token specified") } if cfg.AkamaiClientSecret == "" && cfg.AkamaiEdgercPath != "" { return errors.New("no Akamai client secret specified") } if cfg.AkamaiAccessToken == "" && cfg.AkamaiEdgercPath != "" { return errors.New("no Akamai access token specified") } return nil } func validateConfigForRfc2136(cfg *externaldns.Config) error { if cfg.RFC2136MinTTL < 0 { return errors.New("TTL specified for rfc2136 is negative") } if cfg.RFC2136Insecure && cfg.RFC2136GSSTSIG { return errors.New("--rfc2136-insecure and --rfc2136-gss-tsig are mutually exclusive arguments") } if cfg.RFC2136GSSTSIG { if cfg.RFC2136KerberosPassword == "" || cfg.RFC2136KerberosUsername == "" || cfg.RFC2136KerberosRealm == "" { return errors.New("--rfc2136-kerberos-realm, --rfc2136-kerberos-username, and --rfc2136-kerberos-password are required when specifying --rfc2136-gss-tsig option") } } if cfg.RFC2136BatchChangeSize < 1 { return errors.New("batch size specified for rfc2136 cannot be less than 1") } return nil } ================================================ FILE: pkg/apis/externaldns/validation/validation_test.go ================================================ /* Copyright 2017 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 validation import ( "testing" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestValidateFlags(t *testing.T) { cfg := newValidConfig(t) require.NoError(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.LogFormat = "test" require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.LogFormat = "" require.Error(t, ValidateConfig(cfg)) for _, format := range []string{"text", "json"} { cfg = newValidConfig(t) cfg.LogFormat = format require.NoError(t, ValidateConfig(cfg)) } cfg = newValidConfig(t) cfg.Sources = []string{} require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.Provider = "" require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.IgnoreHostnameAnnotation = true cfg.FQDNTemplate = "" require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.TXTPrefix = "foo" cfg.TXTSuffix = "bar" require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.LabelFilter = "foo" require.NoError(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.LabelFilter = "foo=bar" require.NoError(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.LabelFilter = "#invalid-selector" require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.AnnotationPrefix = "" require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.AnnotationPrefix = "custom.io" require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.AnnotationPrefix = "custom.io/" require.NoError(t, ValidateConfig(cfg)) cfg = newValidConfig(t) cfg.AnnotationPrefix = "external-dns.alpha.kubernetes.io/" require.NoError(t, ValidateConfig(cfg)) } func newValidConfig(t *testing.T) *externaldns.Config { cfg := externaldns.NewConfig() cfg.LogFormat = "json" cfg.Sources = []string{"test-source"} cfg.Provider = "test-provider" require.NoError(t, ValidateConfig(cfg)) return cfg } func TestValidateBadIgnoreHostnameAnnotationsConfig(t *testing.T) { cfg := externaldns.NewConfig() cfg.IgnoreHostnameAnnotation = true cfg.FQDNTemplate = "" assert.Error(t, ValidateConfig(cfg)) } func TestValidateBadRfc2136Config(t *testing.T) { cfg := externaldns.NewConfig() cfg.LogFormat = "json" cfg.Sources = []string{"test-source"} cfg.Provider = "rfc2136" cfg.RFC2136MinTTL = -1 cfg.RFC2136CreatePTR = false cfg.RFC2136BatchChangeSize = 50 err := ValidateConfig(cfg) assert.Error(t, err) } func TestValidateBadRfc2136Batch(t *testing.T) { cfg := externaldns.NewConfig() cfg.LogFormat = "json" cfg.Sources = []string{"test-source"} cfg.Provider = "rfc2136" cfg.RFC2136MinTTL = 3600 cfg.RFC2136BatchChangeSize = 0 err := ValidateConfig(cfg) assert.Error(t, err) } func TestValidateGoodRfc2136Config(t *testing.T) { cfg := externaldns.NewConfig() cfg.LogFormat = "json" cfg.Sources = []string{"test-source"} cfg.Provider = "rfc2136" cfg.RFC2136MinTTL = 3600 cfg.RFC2136BatchChangeSize = 50 err := ValidateConfig(cfg) assert.NoError(t, err) } func TestValidateBadRfc2136GssTsigConfig(t *testing.T) { invalidRfc2136GssTsigConfigs := []*externaldns.Config{ { LogFormat: "json", Sources: []string{"test-source"}, Provider: "rfc2136", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", RFC2136GSSTSIG: true, RFC2136KerberosRealm: "test-realm", RFC2136KerberosUsername: "test-user", RFC2136KerberosPassword: "", RFC2136MinTTL: 3600, RFC2136BatchChangeSize: 50, }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "rfc2136", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", RFC2136GSSTSIG: true, RFC2136KerberosRealm: "test-realm", RFC2136KerberosUsername: "", RFC2136KerberosPassword: "test-pass", RFC2136MinTTL: 3600, RFC2136BatchChangeSize: 50, }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "rfc2136", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", RFC2136GSSTSIG: true, RFC2136Insecure: true, RFC2136KerberosRealm: "test-realm", RFC2136KerberosUsername: "test-user", RFC2136KerberosPassword: "test-pass", RFC2136MinTTL: 3600, RFC2136BatchChangeSize: 50, }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "rfc2136", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", RFC2136GSSTSIG: true, RFC2136KerberosRealm: "", RFC2136KerberosUsername: "test-user", RFC2136KerberosPassword: "", RFC2136MinTTL: 3600, RFC2136BatchChangeSize: 50, }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "rfc2136", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", RFC2136GSSTSIG: true, RFC2136KerberosRealm: "", RFC2136KerberosUsername: "", RFC2136KerberosPassword: "test-pass", RFC2136MinTTL: 3600, RFC2136BatchChangeSize: 50, }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "rfc2136", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", RFC2136GSSTSIG: true, RFC2136Insecure: true, RFC2136KerberosRealm: "", RFC2136KerberosUsername: "test-user", RFC2136KerberosPassword: "test-pass", RFC2136MinTTL: 3600, RFC2136BatchChangeSize: 50, }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "rfc2136", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", RFC2136GSSTSIG: true, RFC2136KerberosRealm: "", RFC2136KerberosUsername: "test-user", RFC2136KerberosPassword: "test-pass", RFC2136MinTTL: 3600, RFC2136BatchChangeSize: 50, }, } for _, cfg := range invalidRfc2136GssTsigConfigs { err := ValidateConfig(cfg) assert.Error(t, err) } } func TestValidateGoodRfc2136GssTsigConfig(t *testing.T) { validRfc2136GssTsigConfigs := []*externaldns.Config{ { LogFormat: "json", Sources: []string{"test-source"}, Provider: "rfc2136", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", RFC2136GSSTSIG: true, RFC2136Insecure: false, RFC2136KerberosRealm: "test-realm", RFC2136KerberosUsername: "test-user", RFC2136KerberosPassword: "test-pass", RFC2136MinTTL: 3600, RFC2136BatchChangeSize: 50, }, } for _, cfg := range validRfc2136GssTsigConfigs { err := ValidateConfig(cfg) assert.NoError(t, err) } } func TestValidateBadAkamaiConfig(t *testing.T) { invalidAkamaiConfigs := []*externaldns.Config{ { LogFormat: "json", Sources: []string{"test-source"}, Provider: "akamai", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", AkamaiClientToken: "test-token", AkamaiClientSecret: "test-secret", AkamaiAccessToken: "test-access-token", AkamaiEdgercPath: "/path/to/edgerc", // Missing AkamaiServiceConsumerDomain }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "akamai", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", AkamaiServiceConsumerDomain: "test-domain", AkamaiClientSecret: "test-secret", AkamaiAccessToken: "test-access-token", AkamaiEdgercPath: "/path/to/edgerc", // Missing AkamaiClientToken }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "akamai", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", AkamaiServiceConsumerDomain: "test-domain", AkamaiClientToken: "test-token", AkamaiAccessToken: "test-access-token", AkamaiEdgercPath: "/path/to/edgerc", // Missing AkamaiClientSecret }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "akamai", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", AkamaiServiceConsumerDomain: "test-domain", AkamaiClientToken: "test-token", AkamaiClientSecret: "test-secret", AkamaiEdgercPath: "/path/to/edgerc", // Missing AkamaiAccessToken }, } for _, cfg := range invalidAkamaiConfigs { err := ValidateConfig(cfg) assert.Error(t, err) } } func TestValidateGoodAkamaiConfig(t *testing.T) { validAkamaiConfigs := []*externaldns.Config{ { LogFormat: "json", Sources: []string{"test-source"}, Provider: "akamai", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", AkamaiServiceConsumerDomain: "test-domain", AkamaiClientToken: "test-token", AkamaiClientSecret: "test-secret", AkamaiAccessToken: "test-access-token", AkamaiEdgercPath: "/path/to/edgerc", }, { LogFormat: "json", Sources: []string{"test-source"}, Provider: "akamai", AnnotationPrefix: "external-dns.alpha.kubernetes.io/", // All Akamai fields can be empty if AkamaiEdgercPath is not specified }, } for _, cfg := range validAkamaiConfigs { err := ValidateConfig(cfg) assert.NoError(t, err) } } func TestValidateBadAzureConfig(t *testing.T) { cfg := externaldns.NewConfig() cfg.LogFormat = "json" cfg.Sources = []string{"test-source"} cfg.Provider = "azure" cfg.AnnotationPrefix = "external-dns.alpha.kubernetes.io/" // AzureConfigFile is empty err := ValidateConfig(cfg) assert.Error(t, err) } func TestValidateGoodAzureConfig(t *testing.T) { cfg := externaldns.NewConfig() cfg.LogFormat = "json" cfg.Sources = []string{"test-source"} cfg.Provider = "azure" cfg.AnnotationPrefix = "external-dns.alpha.kubernetes.io/" cfg.AzureConfigFile = "/path/to/azure.json" err := ValidateConfig(cfg) assert.NoError(t, err) } ================================================ FILE: pkg/apis/externaldns/version.go ================================================ /* 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 externaldns import ( "fmt" "runtime" ) const ( bannerTemplate = `GitCommitShort=%s, GoVersion=%s, Platform=%s, UserAgent=%s` ) var ( Version = "unknown" // Set at the build time via `-ldflags "-X main.Version="` GitCommit = "unknown" // Set at the build time via `-ldflags "-X main.GitCommitSHA="` UserAgentProduct = "ExternalDNS" goVersion = runtime.Version() ) func UserAgent() string { return fmt.Sprintf("%s/%s", UserAgentProduct, Version) } func Banner() string { platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) return fmt.Sprintf( bannerTemplate, GitCommit, goVersion, platform, UserAgent(), ) } ================================================ FILE: pkg/apis/externaldns/version_test.go ================================================ /* 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 externaldns import ( "testing" "github.com/stretchr/testify/assert" ) func TestBanner(t *testing.T) { // Set variables to known values Version = "1.0.0" goVersion = "go1.17" GitCommit = "49a0c57c7" want := Banner() assert.Contains(t, want, "GoVersion=go1.17") assert.Contains(t, want, "GitCommitShort=49a0c57c7") assert.Contains(t, want, "UserAgent=ExternalDNS/1.0.0") } ================================================ FILE: pkg/client/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - client ================================================ FILE: pkg/client/config.go ================================================ /* Copyright 2026 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 kubeclient provides shared utilities for creating Kubernetes REST configurations // and clients with standardized metrics instrumentation. package kubeclient import ( "os" "time" log "github.com/sirupsen/logrus" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" extdnshttp "sigs.k8s.io/external-dns/pkg/http" ) // GetRestConfig returns the REST client configuration for Kubernetes API access. // Supports both in-cluster and external cluster configurations. // // Configuration Priority: // 1. KubeConfig file if specified // 2. Recommended home file (~/.kube/config) // 3. In-cluster config // TODO: consider clientcmd.NewDefaultClientConfigLoadingRules() with clientcmd.NewNonInteractiveDeferredLoadingClientConfig func GetRestConfig(kubeConfig, apiServerURL string) (*rest.Config, error) { if kubeConfig == "" { if _, err := os.Stat(clientcmd.RecommendedHomeFile); err == nil { kubeConfig = clientcmd.RecommendedHomeFile } } log.Debugf("apiServerURL: %s", apiServerURL) log.Debugf("kubeConfig: %s", kubeConfig) // evaluate whether to use kubeConfig-file or serviceaccount-token var ( config *rest.Config err error ) if kubeConfig == "" { log.Debug("Using inCluster-config based on serviceaccount-token") config, err = rest.InClusterConfig() } else { log.Debug("Using kubeConfig") config, err = clientcmd.BuildConfigFromFlags(apiServerURL, kubeConfig) } if err != nil { return nil, err } return config, nil } // InstrumentedRESTConfig creates a REST config with request instrumentation for monitoring. // Adds HTTP transport wrapper for Prometheus metrics collection and request timeout configuration. // // Metrics: Wraps the transport with pkg/http.NewInstrumentedTransport to collect // HTTP request duration metrics for all Kubernetes API calls. // // Timeout: Applies the specified request timeout to prevent hanging requests. func InstrumentedRESTConfig(kubeConfig, apiServerURL string, requestTimeout time.Duration) (*rest.Config, error) { config, err := GetRestConfig(kubeConfig, apiServerURL) if err != nil { return nil, err } config.WrapTransport = extdnshttp.NewInstrumentedTransport config.Timeout = requestTimeout return config, nil } // NewKubeClient returns a new Kubernetes client object. It takes a Config and // uses APIServerURL and KubeConfig attributes to connect to the cluster. If // KubeConfig isn't provided it defaults to using the recommended default. func NewKubeClient(kubeConfig, apiServerURL string, requestTimeout time.Duration) (*kubernetes.Clientset, error) { log.Infof("Instantiating new Kubernetes client") config, err := InstrumentedRESTConfig(kubeConfig, apiServerURL, requestTimeout) if err != nil { return nil, err } client, err := kubernetes.NewForConfig(config) if err != nil { return nil, err } log.Infof("Created Kubernetes client %s", config.Host) return client, nil } ================================================ FILE: pkg/client/config_test.go ================================================ /* Copyright 2026 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 kubeclient import ( "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/client-go/tools/clientcmd" ) func TestGetRestConfig_WithKubeConfig(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) defer svr.Close() mockKubeCfgDir := filepath.Join(t.TempDir(), ".kube") mockKubeCfgPath := filepath.Join(mockKubeCfgDir, "config") err := os.MkdirAll(mockKubeCfgDir, 0755) require.NoError(t, err) kubeCfgTemplate := `apiVersion: v1 kind: Config clusters: - cluster: server: %s name: test-cluster contexts: - context: cluster: test-cluster user: test-user name: test-context current-context: test-context users: - name: test-user user: token: fake-token ` err = os.WriteFile(mockKubeCfgPath, fmt.Appendf(nil, kubeCfgTemplate, svr.URL), 0644) require.NoError(t, err) config, err := GetRestConfig(mockKubeCfgPath, "") require.NoError(t, err) require.NotNil(t, config) assert.Equal(t, svr.URL, config.Host) } func TestInstrumentedRESTConfig_AddsMetrics(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) defer svr.Close() mockKubeCfgDir := filepath.Join(t.TempDir(), ".kube") mockKubeCfgPath := filepath.Join(mockKubeCfgDir, "config") err := os.MkdirAll(mockKubeCfgDir, 0755) require.NoError(t, err) kubeCfgTemplate := `apiVersion: v1 kind: Config clusters: - cluster: server: %s name: test-cluster contexts: - context: cluster: test-cluster user: test-user name: test-context current-context: test-context users: - name: test-user user: token: fake-token ` err = os.WriteFile(mockKubeCfgPath, fmt.Appendf(nil, kubeCfgTemplate, svr.URL), 0644) require.NoError(t, err) timeout := 30 * time.Second config, err := InstrumentedRESTConfig(mockKubeCfgPath, "", timeout) require.NoError(t, err) require.NotNil(t, config) assert.Equal(t, timeout, config.Timeout) assert.NotNil(t, config.WrapTransport, "WrapTransport should be set for metrics") } func TestGetRestConfig_RecommendedHomeFile(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) defer svr.Close() mockKubeCfgDir := filepath.Join(t.TempDir(), ".kube") mockKubeCfgPath := filepath.Join(mockKubeCfgDir, "config") err := os.MkdirAll(mockKubeCfgDir, 0755) require.NoError(t, err) kubeCfgTemplate := `apiVersion: v1 kind: Config clusters: - cluster: server: %s name: test-cluster contexts: - context: cluster: test-cluster user: test-user name: test-context current-context: test-context ` err = os.WriteFile(mockKubeCfgPath, fmt.Appendf(nil, kubeCfgTemplate, svr.URL), 0644) require.NoError(t, err) prevRecommendedHomeFile := clientcmd.RecommendedHomeFile t.Cleanup(func() { clientcmd.RecommendedHomeFile = prevRecommendedHomeFile }) clientcmd.RecommendedHomeFile = mockKubeCfgPath config, err := GetRestConfig("", "") require.NoError(t, err) require.NotNil(t, config) assert.Equal(t, svr.URL, config.Host) } ================================================ FILE: pkg/events/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - events ================================================ FILE: pkg/events/controller.go ================================================ /* 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 events import ( "context" "os" log "github.com/sirupsen/logrus" eventsv1 "k8s.io/api/events/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/wait" v1 "k8s.io/client-go/kubernetes/typed/events/v1" "k8s.io/client-go/util/workqueue" ) const ( workers = 1 controllerName = "external-dns" maxTriesPerEvent = 3 maxQueuedEvents = 100 ) type EventEmitter interface { Add(...Event) } type Controller struct { client v1.EventsV1Interface queue workqueue.TypedRateLimitingInterface[any] emitEvents sets.Set[Reason] maxQueuedEvents int dryRun bool hostname string } func NewEventController(client v1.EventsV1Interface, cfg *Config) (*Controller, error) { queue := workqueue.NewTypedRateLimitingQueueWithConfig[any]( workqueue.DefaultTypedControllerRateLimiter[any](), workqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName}, ) hostname, _ := os.Hostname() return &Controller{ client: client, queue: queue, emitEvents: cfg.emitEvents, maxQueuedEvents: maxQueuedEvents, dryRun: cfg.dryRun, hostname: hostname, }, nil } func (ec *Controller) Run(ctx context.Context) { if len(ec.emitEvents) == 0 { return } go ec.run(ctx) } func (ec *Controller) run(ctx context.Context) { log.Info("event Controller started") defer log.Info("event Controller terminated") defer utilruntime.HandleCrash() var waitGroup wait.Group for range workers { waitGroup.StartWithContext(ctx, func(ctx context.Context) { for ec.processNextWorkItem(ctx) { } }) } <-ctx.Done() ec.queue.ShutDownWithDrain() waitGroup.Wait() } func (ec *Controller) processNextWorkItem(ctx context.Context) bool { key, quit := ec.queue.Get() if quit { return false } defer ec.queue.Done(key) event, ok := key.(*eventsv1.Event) if !ok { log.Errorf("failed to convert key to Event: %q", key) return true } var dryRun []string if ec.dryRun { dryRun = []string{metav1.DryRunAll} } _, err := ec.client.Events(event.Namespace).Create(ctx, event, metav1.CreateOptions{ DryRun: dryRun, }) if err != nil && !apierrors.IsNotFound(err) { if ec.queue.NumRequeues(key) < maxTriesPerEvent { log.Errorf("not able to create event, retrying for key/%s. %v", key, err) ec.queue.AddRateLimited(key) return true } log.Errorf("dropping event %s/%s with key/%q after %d retries. %v", event.Namespace, event.Name, key, ec.queue.NumRequeues(key), err) } ec.queue.Forget(key) return true } func (ec *Controller) Add(events ...Event) { if ec.queue.Len() >= ec.maxQueuedEvents { log.Warnf("event queue is full, dropping %d events", len(events)) return } for _, e := range events { event := e.event() if event == nil { continue } ec.emit(event) } } func (ec *Controller) emit(event *eventsv1.Event) { if !ec.emitEvents.Has(Reason(event.Reason)) { log.Debugf("skipping event %s/%s/%s with reason %s as not configured to emit", event.Kind, event.Namespace, event.Name, event.Reason) return } event.ReportingController = controllerName ec.queue.Add(event) } ================================================ FILE: pkg/events/controller_test.go ================================================ /* 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 events import ( "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" eventsv1 "k8s.io/api/events/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes/fake" eventsclient "k8s.io/client-go/kubernetes/typed/events/v1" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/util/workqueue" clienttesting "k8s.io/client-go/testing" ) func TestNewEventController_Success(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) defer svr.Close() mockKubeCfgDir := filepath.Join(t.TempDir(), ".kube") mockKubeCfgPath := filepath.Join(mockKubeCfgDir, "config") err := os.MkdirAll(mockKubeCfgDir, 0755) require.NoError(t, err) kubeCfgTemplate := ` apiVersion: v1 kind: Config clusters: - cluster: server: %s name: test-cluster contexts: - context: cluster: test-cluster user: test-user name: test-context current-context: test-context users: - name: test-user user: token: fake-token ` err = os.WriteFile(mockKubeCfgPath, fmt.Appendf(nil, kubeCfgTemplate, svr.URL), os.FileMode(0755)) require.NoError(t, err) restConfig, err := clientcmd.BuildConfigFromFlags(svr.URL, mockKubeCfgPath) require.NoError(t, err) client, err := eventsclient.NewForConfig(restConfig) require.NoError(t, err) cfg := NewConfig( WithEmitEvents([]string{string(RecordReady)}), ) ctrl, err := NewEventController(client, cfg) require.NoError(t, err) require.NotNil(t, ctrl) require.False(t, ctrl.dryRun) } func TestController_Run_NoEmitEvents(t *testing.T) { kClient := fake.NewClientset() ctrl := &Controller{ client: kClient.EventsV1(), emitEvents: sets.New[Reason](), } require.NotPanics(t, func() { ctrl.Run(t.Context()) }) } func TestController_Run_EmitEvents(t *testing.T) { log.SetLevel(log.ErrorLevel) ctx := t.Context() eventCreated := make(chan struct{}) kubeClient := fake.NewClientset() kubeClient.PrependReactor("create", "events", func(_ clienttesting.Action) (bool, runtime.Object, error) { eventCreated <- struct{}{} return true, nil, nil }) eventsClient := kubeClient.EventsV1() ctrl := &Controller{ client: eventsClient, emitEvents: sets.New[Reason](RecordReady), queue: workqueue.NewTypedRateLimitingQueueWithConfig[any]( workqueue.DefaultTypedControllerRateLimiter[any](), workqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName}, ), hostname: controllerName, maxQueuedEvents: 1, } ctrl.Run(ctx) event := NewEvent(NewObjectReference(&v1.Pod{ TypeMeta: metav1.TypeMeta{ Kind: "Pod", APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: "fake-object", Namespace: v1.NamespaceDefault, UID: "9de3fc19-8aeb-4e76-865d-ada955403103", }, }, "fake-source"), "record created", ActionCreate, RecordReady) ctrl.Add(event) select { case <-eventCreated: case <-time.After(wait.ForeverTestTimeout): t.Fatal("event not created") } } func TestController_Queue_EmitEvents(t *testing.T) { log.SetLevel(log.ErrorLevel) eventsClient := fake.NewClientset().EventsV1() ctrl := &Controller{ client: eventsClient, emitEvents: sets.New[Reason](RecordReady), queue: workqueue.NewTypedRateLimitingQueueWithConfig[any]( workqueue.DefaultTypedControllerRateLimiter[any](), workqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName}, ), hostname: controllerName, maxQueuedEvents: 1, } event := NewEvent(NewObjectReference(&v1.Pod{ TypeMeta: metav1.TypeMeta{ Kind: "Pod", APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: "fake-object", Namespace: v1.NamespaceDefault, UID: "9de3fc19-8aeb-4e76-865d-ada955403103", }, }, "fake-source"), "record created", ActionCreate, RecordReady) ctrl.Add(event) queueItem, shutdown := ctrl.queue.Get() require.False(t, shutdown) value, ok := queueItem.(*eventsv1.Event) assert.True(t, ok) assert.NotNil(t, value) assert.Contains(t, value.Name, "fake-object.") assert.Contains(t, value.Reason, RecordReady) } func TestController_ProcessNextWorkItem_RequeuesOnError(t *testing.T) { log.SetLevel(log.ErrorLevel) ctx := t.Context() createAttempts := 0 kubeClient := fake.NewClientset() kubeClient.PrependReactor("create", "events", func(_ clienttesting.Action) (bool, runtime.Object, error) { createAttempts++ if createAttempts <= maxTriesPerEvent { return true, nil, fmt.Errorf("transient API error") } return true, nil, nil }) eventCreated := make(chan struct{}, 1) kubeClient.PrependReactor("create", "events", func(_ clienttesting.Action) (bool, runtime.Object, error) { createAttempts++ if createAttempts <= maxTriesPerEvent { return true, nil, fmt.Errorf("transient API error") } eventCreated <- struct{}{} return true, nil, nil }) ctrl := &Controller{ client: kubeClient.EventsV1(), emitEvents: sets.New[Reason](RecordReady), queue: workqueue.NewTypedRateLimitingQueueWithConfig[any]( workqueue.NewTypedMaxOfRateLimiter[any]( workqueue.NewTypedItemFastSlowRateLimiter[any](1*time.Millisecond, 1*time.Millisecond, 5), ), workqueue.TypedRateLimitingQueueConfig[any]{Name: controllerName}, ), hostname: controllerName, maxQueuedEvents: maxQueuedEvents, } ctrl.Run(ctx) event := NewEvent(NewObjectReference(&v1.Pod{ TypeMeta: metav1.TypeMeta{ Kind: "Pod", APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: "fake-object", Namespace: v1.NamespaceDefault, UID: "9de3fc19-8aeb-4e76-865d-ada955403103", }, }, "fake-source"), "record created", ActionCreate, RecordReady) ctrl.Add(event) select { case <-eventCreated: assert.Greater(t, createAttempts, 1, "event should have been retried at least once") case <-time.After(5 * time.Second): t.Fatalf("event was not retried and delivered; only %d attempt(s) made", createAttempts) } } ================================================ FILE: pkg/events/fake/fake.go ================================================ /* 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 fake import ( "github.com/stretchr/testify/mock" "sigs.k8s.io/external-dns/pkg/events" ) type EventEmitter struct { mock.Mock } func (m *EventEmitter) Add(events ...events.Event) { m.Called(events[0]) } func NewFakeEventEmitter() *EventEmitter { m := &EventEmitter{} m.On("Add", mock.AnythingOfType("events.Event")) return m } ================================================ FILE: pkg/events/fake/fake_test.go ================================================ /* Copyright 2026 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 fake import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/pkg/events" ) func TestNewFakeEventEmitter(t *testing.T) { emitter := NewFakeEventEmitter() require.NotNil(t, emitter) assert.IsType(t, &EventEmitter{}, emitter) } func TestEventEmitter_Add_SingleEvent(t *testing.T) { emitter := NewFakeEventEmitter() event := events.NewEvent(nil, "test message", events.ActionCreate, events.RecordReady) emitter.Add(event) emitter.AssertExpectations(t) } func TestEventEmitter_Add_MultipleEvents(t *testing.T) { emitter := NewFakeEventEmitter() event1 := events.NewEvent(nil, "test message 1", events.ActionCreate, events.RecordReady) event2 := events.NewEvent(nil, "test message 2", events.ActionUpdate, events.RecordReady) // Note: The implementation only processes events[0], so we test that behavior emitter.Add(event1, event2) emitter.AssertExpectations(t) } func TestEventEmitter_Add_WithDifferentEventTypes(t *testing.T) { tests := []struct { name string action events.Action reason events.Reason }{ { name: "create action with RecordReady", action: events.ActionCreate, reason: events.RecordReady, }, { name: "update action with RecordReady", action: events.ActionUpdate, reason: events.RecordReady, }, { name: "delete action with RecordDeleted", action: events.ActionDelete, reason: events.RecordDeleted, }, { name: "failed action with RecordError", action: events.ActionFailed, reason: events.RecordError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { emitter := NewFakeEventEmitter() event := events.NewEvent(nil, "test message", tt.action, tt.reason) emitter.Add(event) emitter.AssertExpectations(t) }) } } func TestEventEmitter_Add_VerifyMockCalled(t *testing.T) { emitter := &EventEmitter{} event := events.NewEvent(nil, "test message", events.ActionCreate, events.RecordReady) emitter.On("Add", event).Return() emitter.Add(event) emitter.AssertExpectations(t) } func TestEventEmitter_Add_VerifyMockCalledWithAnyEvent(t *testing.T) { emitter := NewFakeEventEmitter() event := events.NewEvent(nil, "test message", events.ActionCreate, events.RecordReady) emitter.Add(event) // NewFakeEventEmitter sets up mock.AnythingOfType, so this should pass emitter.AssertExpectations(t) } func TestEventEmitter_Add_EmptyEventsPanics(t *testing.T) { emitter := NewFakeEventEmitter() // The Add method accesses events[0] without checking if events is empty // This will panic if called with no arguments assert.Panics(t, func() { emitter.Add() }, "Add() should panic when called with no events") } ================================================ FILE: pkg/events/types.go ================================================ /* 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 events import ( "fmt" "reflect" "regexp" "slices" "strings" "time" log "github.com/sirupsen/logrus" apiv1 "k8s.io/api/core/v1" eventsv1 "k8s.io/api/events/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/kubernetes/scheme" runtime "sigs.k8s.io/controller-runtime/pkg/client" ) const ( ActionCreate Action = "Created" ActionUpdate Action = "Updated" ActionDelete Action = "Deleted" ActionFailed Action = "FailedSync" RecordReady Reason = "RecordReady" RecordDeleted Reason = "RecordDeleted" RecordError Reason = "RecordError" EventTypeNormal EventType = EventType(apiv1.EventTypeNormal) EventTypeWarning EventType = EventType(apiv1.EventTypeWarning) ) var ( invalidChars = regexp.MustCompile(`[^a-z0-9.\-]`) startsWithAlphaNumeric = regexp.MustCompile(`^[a-z0-9]`) endsWithAlphaNumeric = regexp.MustCompile(`[a-z0-9]$`) ) type ( // Action values for actions Action string // Reason types of Event Reasons Reason string // EventType values for event types EventType string ConfigOption func(*Config) Event struct { ref ObjectReference message string source string action Action eType EventType reason Reason } // ObjectReference holds metadata about a Kubernetes object for event correlation. // TODO: consider make fields private. Ensuring data integrity, encapsulation and immutability. ObjectReference struct { Kind string ApiVersion string Namespace string Name string UID types.UID Source string } Config struct { emitEvents sets.Set[Reason] dryRun bool } // EndpointInfo defines the interface for endpoint data needed to create events. EndpointInfo interface { GetDNSName() string GetRecordType() string GetRecordTTL() int64 GetTargets() []string GetOwner() string RefObject() *ObjectReference } ) func NewObjectReference(obj runtime.Object, source string) *ObjectReference { // Kubernetes API doesn't populate TypeMeta (Kind/APIVersion) when retrieving // objects via informers. Look up the Kind from the scheme without mutating the object. gvk := obj.GetObjectKind().GroupVersionKind() if gvk.Kind == "" { gvks, _, err := scheme.Scheme.ObjectKinds(obj) if err == nil && len(gvks) > 0 { gvk = gvks[0] } else { // Fallback to reflection for types not in scheme gvk = schema.GroupVersionKind{Kind: reflect.TypeOf(obj).Elem().Name()} } } return &ObjectReference{ Kind: gvk.Kind, ApiVersion: gvk.GroupVersion().String(), Namespace: obj.GetNamespace(), Name: obj.GetName(), UID: obj.GetUID(), Source: source, } } func NewEvent(obj *ObjectReference, msg string, a Action, r Reason) Event { if obj == nil { return Event{} } return Event{ ref: *obj, message: msg, eType: EventTypeNormal, action: a, reason: r, source: obj.Source, } } // NewEventFromEndpoint creates an Event from an EndpointInfo with formatted message. func NewEventFromEndpoint(ep EndpointInfo, a Action, r Reason) Event { if ep == nil || ep.RefObject() == nil { return Event{} } msg := fmt.Sprintf("(external-dns) record:%s,owner:%s,type:%s,ttl:%d,targets:%s", ep.GetDNSName(), ep.GetOwner(), ep.GetRecordType(), ep.GetRecordTTL(), strings.Join(ep.GetTargets(), ",")) return NewEvent(ep.RefObject(), msg, a, r) } func (e *Event) description() string { return fmt.Sprintf("%s/%s/%s", e.ref.Kind, e.ref.Namespace, e.ref.Name) } func (e *Event) Action() Action { return e.action } func (e *Event) Reason() Reason { return e.reason } func (e *Event) EventType() EventType { return e.eType } func (e *Event) event() *eventsv1.Event { if e.ref.Name == "" { log.Debug("skipping event for resources as the name is not generated yet") return nil } message := e.message // https://github.com/kubernetes/api/blob/7da28ad7db85e33ab8be3b89e63cad4c07b37fb2/events/v1/types.go#L77 if len(message) > 1024 { message = message[0:1021] + "..." } timestamp := metav1.MicroTime{Time: time.Now()} // Events are namespaced resources. For cluster-scoped objects like Nodes, // the namespace is empty, so we default to "default" namespace. namespace := e.ref.Namespace if namespace == "" { namespace = "default" } event := &eventsv1.Event{ ObjectMeta: metav1.ObjectMeta{ Name: sanitize(e.ref.Name), Namespace: namespace, }, EventTime: timestamp, ReportingInstance: controllerName + "/source/" + e.ref.Source, ReportingController: controllerName, Action: string(e.action), Reason: string(e.reason), Note: message, Type: string(e.eType), } if e.ref.UID != "" { ref := e.ref.objectRef() event.Related = ref event.Regarding = *ref } return event } // Sanitize input to comply with RFC 1123 subdomain naming requirements func sanitize(input string) string { t := metav1.Time{Time: time.Now()} if input == "" { return fmt.Sprintf("a.%x", t.UnixNano()) } sanitized := invalidChars.ReplaceAllString(strings.ToLower(input), "-") // the name should start with an alphanumeric character if len(sanitized) > 0 && !startsWithAlphaNumeric.MatchString(sanitized) { sanitized = "a" + sanitized } // the name should end with an alphanumeric character if len(sanitized) > 0 && !endsWithAlphaNumeric.MatchString(sanitized) { sanitized += "z" } sanitized = invalidChars.ReplaceAllString(sanitized, "-") return fmt.Sprintf("%v.%x", sanitized, t.UnixNano()) } func WithDryRun(dryRun bool) ConfigOption { return func(c *Config) { c.dryRun = dryRun } } func WithEmitEvents(events []string) ConfigOption { return func(c *Config) { if len(events) > 0 { c.emitEvents = sets.New[Reason]() for _, event := range events { if slices.Contains([]string{string(RecordReady), string(RecordError)}, event) { c.emitEvents.Insert(Reason(event)) } } } } } func NewConfig(opts ...ConfigOption) *Config { c := &Config{} for _, opt := range opts { opt(c) } return c } func (c *Config) IsEnabled() bool { return len(c.emitEvents) > 0 } func (r *ObjectReference) objectRef() *apiv1.ObjectReference { return &apiv1.ObjectReference{ Kind: r.Kind, Namespace: r.Namespace, Name: r.Name, UID: r.UID, APIVersion: r.ApiVersion, } } ================================================ FILE: pkg/events/types_test.go ================================================ /* 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 events import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" apiv1 "k8s.io/api/core/v1" eventsv1 "k8s.io/api/events/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" ctrlruntime "sigs.k8s.io/controller-runtime/pkg/client" ) func TestNewObjectReference_DoesNotMutateObject(t *testing.T) { // Verify that NewObjectReference does NOT mutate the original object pod := &apiv1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "test-pod", Namespace: "default", }, } podCopy := pod.DeepCopy() _ = NewObjectReference(pod, "test") assert.Equal(t, podCopy, pod) } func TestSanitize(t *testing.T) { tests := []struct { input string expected string // expected prefix of sanitized output }{ {"My.Resource_1", "my.resource-1."}, {"!@#bad*chars", "a---bad-chars."}, {"-start", "a-start."}, {"end-", "end-z."}, {"-both-", "a-both-z."}, {"", "a."}, {"ALLCAPS", "allcaps."}, {"foo.bar", "foo.bar."}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { result := sanitize(tt.input) require.True(t, strings.HasPrefix(result, tt.expected), "expected prefix %q, got %q", tt.expected, result) require.Greater(t, len(result), len(tt.expected)) }) } } func TestEvent_Reference(t *testing.T) { tests := []struct { kind string namespace string name string expected string }{ {"Pod", "default", "nginx", "Pod/default/nginx"}, {"Service", "prod", "api", "Service/prod/api"}, {"", "", "", "//"}, } for _, tt := range tests { ev := Event{ ref: ObjectReference{ Kind: tt.kind, Namespace: tt.namespace, Name: tt.name, Source: "fake-source", }, } require.Equal(t, tt.expected, ev.description()) } } func TestEvent_NewEvents(t *testing.T) { tests := []struct { name string event Event asserts func(e *eventsv1.Event) }{ { name: "empty event", event: NewEvent(nil, "", ActionCreate, RecordReady), asserts: func(e *eventsv1.Event) { require.Nil(t, e) }, }, { name: "event without uuid", event: NewEvent(NewObjectReference(&apiv1.Pod{ TypeMeta: metav1.TypeMeta{ Kind: "Pod", APIVersion: "apiv1", }, ObjectMeta: metav1.ObjectMeta{ Name: "fake-pod", Namespace: apiv1.NamespaceDefault, }, }, "fake"), "", ActionCreate, RecordReady), asserts: func(e *eventsv1.Event) { require.NotNil(t, e) require.Contains(t, e.Name, "fake-pod.") require.Equal(t, apiv1.NamespaceDefault, e.Namespace) require.Nil(t, e.Related) require.Equal(t, apiv1.ObjectReference{}, e.Regarding) }, }, { name: "event with uuid", event: NewEvent(NewObjectReference(&apiv1.Pod{ TypeMeta: metav1.TypeMeta{ Kind: "Pod", APIVersion: "apiv1", }, ObjectMeta: metav1.ObjectMeta{ Name: "fake-pod", Namespace: apiv1.NamespaceDefault, UID: "9de3fc19-8aeb-4e76-865d-ada955403103", }, }, "fake"), "", ActionCreate, RecordReady), asserts: func(e *eventsv1.Event) { require.NotNil(t, e) require.Contains(t, e.Name, "fake-pod.") require.NotNil(t, e.Related) require.NotNil(t, e.Regarding) }, }, } for _, tt := range tests { t.Run(tt.name, func(_ *testing.T) { tt.asserts(tt.event.event()) }) } } func TestEvent_Transpose(t *testing.T) { ev := NewEvent(&ObjectReference{ Kind: "Pod", Namespace: "default", Name: "nginx", }, "test message", ActionCreate, RecordReady) event := ev.event() require.NotNil(t, event) require.Contains(t, event.ObjectMeta.Name, ev.ref.Name) require.Equal(t, "default", event.ObjectMeta.Namespace) require.Equal(t, string(ActionCreate), event.Action) require.Equal(t, string(RecordReady), event.Reason) require.Equal(t, "test message", event.Note) require.Equal(t, apiv1.EventTypeNormal, event.Type) require.Equal(t, controllerName, event.ReportingController) require.Contains(t, event.ReportingInstance, controllerName+"/source/") longMsg := strings.Repeat("a", 2000) ev.message = longMsg event = ev.event() require.Equal(t, longMsg[:1021]+"...", event.Note) ev.ref.Name = "" require.Nil(t, ev.event()) } func TestWithEmitEvents(t *testing.T) { tests := []struct { name string input []string expected sets.Set[Reason] assert func(c *Config) }{ { name: "valid events", input: []string{string(RecordReady), string(RecordError)}, expected: sets.New[Reason](RecordReady, RecordError), assert: func(c *Config) { require.Equal(t, sets.New[Reason](RecordReady, RecordError), c.emitEvents) require.True(t, c.IsEnabled()) }, }, { name: "invalid event", input: []string{"InvalidEvent"}, expected: sets.New[Reason](), assert: func(c *Config) { require.Equal(t, sets.New[Reason](), c.emitEvents) require.False(t, c.IsEnabled()) }, }, { name: "mixed valid and invalid", input: []string{string(RecordReady), "InvalidEvent"}, expected: sets.New[Reason](RecordReady), assert: func(c *Config) { require.Equal(t, sets.New[Reason](RecordReady), c.emitEvents) require.True(t, c.IsEnabled()) }, }, { name: "empty input", input: []string{}, expected: nil, assert: func(c *Config) { require.NotNil(t, c) require.False(t, c.IsEnabled()) }, }, } for _, tt := range tests { t.Run(tt.name, func(_ *testing.T) { cfg := &Config{} opt := WithEmitEvents(tt.input) opt(cfg) tt.assert(cfg) }) } } // mockEndpointInfo implements EndpointInfo for testing type mockEndpointInfo struct { dnsName string recordType string recordTTL int64 targets []string owner string refObject *ObjectReference } func (m *mockEndpointInfo) GetDNSName() string { return m.dnsName } func (m *mockEndpointInfo) GetRecordType() string { return m.recordType } func (m *mockEndpointInfo) GetRecordTTL() int64 { return m.recordTTL } func (m *mockEndpointInfo) GetTargets() []string { return m.targets } func (m *mockEndpointInfo) GetOwner() string { return m.owner } func (m *mockEndpointInfo) RefObject() *ObjectReference { return m.refObject } func TestNewEventFromEndpoint(t *testing.T) { tests := []struct { name string ep EndpointInfo action Action reason Reason asserts func(t *testing.T, ev Event) }{ { name: "nil endpoint returns empty event", ep: nil, action: ActionCreate, reason: RecordReady, asserts: func(t *testing.T, ev Event) { require.Equal(t, Event{}, ev) }, }, { name: "endpoint with nil RefObject returns empty event", ep: &mockEndpointInfo{ dnsName: "example.com", recordType: "A", recordTTL: 300, targets: []string{"10.0.0.1"}, owner: "default", refObject: nil, }, action: ActionCreate, reason: RecordReady, asserts: func(t *testing.T, ev Event) { require.Equal(t, Event{}, ev) }, }, { name: "valid endpoint with create action", ep: &mockEndpointInfo{ dnsName: "test.example.com", recordType: "A", recordTTL: 300, targets: []string{"10.0.0.1", "10.0.0.2"}, owner: "my-owner", refObject: &ObjectReference{ Kind: "Service", Namespace: "default", Name: "my-service", Source: "service", }, }, action: ActionCreate, reason: RecordReady, asserts: func(t *testing.T, ev Event) { require.Equal(t, ActionCreate, ev.action) require.Equal(t, RecordReady, ev.reason) require.Equal(t, EventTypeNormal, ev.eType) require.Equal(t, "Service", ev.ref.Kind) require.Equal(t, "default", ev.ref.Namespace) require.Equal(t, "my-service", ev.ref.Name) require.Contains(t, ev.message, "record:test.example.com") require.Contains(t, ev.message, "owner:my-owner") require.Contains(t, ev.message, "type:A") require.Contains(t, ev.message, "ttl:300") require.Contains(t, ev.message, "targets:10.0.0.1,10.0.0.2") require.Contains(t, ev.message, "(external-dns)") }, }, { name: "valid endpoint with delete action", ep: &mockEndpointInfo{ dnsName: "deleted.example.com", recordType: "CNAME", recordTTL: 0, targets: []string{"target.example.com"}, owner: "", refObject: &ObjectReference{ Kind: "Ingress", Namespace: "prod", Name: "my-ingress", Source: "ingress", }, }, action: ActionDelete, reason: RecordDeleted, asserts: func(t *testing.T, ev Event) { require.Equal(t, ActionDelete, ev.action) require.Equal(t, RecordDeleted, ev.reason) require.Contains(t, ev.message, "record:deleted.example.com") require.Contains(t, ev.message, "type:CNAME") require.Contains(t, ev.message, "ttl:0") }, }, { name: "endpoint for cluster-scoped resource (Node)", ep: &mockEndpointInfo{ dnsName: "node1.example.com", recordType: "A", recordTTL: 60, targets: []string{"192.168.1.1"}, owner: "default", refObject: &ObjectReference{ Kind: "Node", Namespace: "", // cluster-scoped Name: "node1", Source: "node", }, }, action: ActionCreate, reason: RecordReady, asserts: func(t *testing.T, ev Event) { require.Equal(t, ActionCreate, ev.action) require.Empty(t, ev.ref.Namespace) k8sEvent := ev.event() require.NotNil(t, k8sEvent) require.Equal(t, "default", k8sEvent.Namespace) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ev := NewEventFromEndpoint(tt.ep, tt.action, tt.reason) tt.asserts(t, ev) }) } } func TestNewObjectReference(t *testing.T) { tests := []struct { name string obj ctrlruntime.Object source string expected *ObjectReference }{ { name: "Pod with TypeMeta already set", obj: &apiv1.Pod{ TypeMeta: metav1.TypeMeta{ Kind: "Pod", APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: "my-pod", Namespace: "default", UID: "pod-uid-123", }, }, source: "pod", expected: &ObjectReference{ Kind: "Pod", ApiVersion: "v1", Namespace: "default", Name: "my-pod", UID: "pod-uid-123", Source: "pod", }, }, { name: "Pod without TypeMeta (simulating informer behavior)", obj: &apiv1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: "informer-pod", Namespace: "kube-system", UID: "informer-uid-456", }, }, source: "pod", expected: &ObjectReference{ Kind: "Pod", ApiVersion: "v1", Namespace: "kube-system", Name: "informer-pod", UID: "informer-uid-456", Source: "pod", }, }, { name: "Service without TypeMeta", obj: &apiv1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "my-service", Namespace: "prod", UID: "svc-uid-789", }, }, source: "service", expected: &ObjectReference{ Kind: "Service", ApiVersion: "v1", Namespace: "prod", Name: "my-service", UID: "svc-uid-789", Source: "service", }, }, { name: "Node (cluster-scoped, no namespace)", obj: &apiv1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: "worker-node-1", UID: "node-uid-abc", }, }, source: "node", expected: &ObjectReference{ Kind: "Node", ApiVersion: "v1", Namespace: "", Name: "worker-node-1", UID: "node-uid-abc", Source: "node", }, }, { name: "Endpoints without TypeMeta", obj: &apiv1.Endpoints{ ObjectMeta: metav1.ObjectMeta{ Name: "my-endpoints", Namespace: "default", UID: "ep-uid-def", }, }, source: "endpoints", expected: &ObjectReference{ Kind: "Endpoints", ApiVersion: "v1", Namespace: "default", Name: "my-endpoints", UID: "ep-uid-def", Source: "endpoints", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := NewObjectReference(tt.obj, tt.source) require.Equal(t, tt.expected.Kind, result.Kind) require.Equal(t, tt.expected.ApiVersion, result.ApiVersion) require.Equal(t, tt.expected.Namespace, result.Namespace) require.Equal(t, tt.expected.Name, result.Name) require.Equal(t, tt.expected.UID, result.UID) require.Equal(t, tt.expected.Source, result.Source) }) } } // customObject is a type not registered in the scheme, used to test reflection fallback type customObject struct { metav1.TypeMeta metav1.ObjectMeta } func (c *customObject) DeepCopyObject() runtime.Object { return &customObject{ TypeMeta: c.TypeMeta, ObjectMeta: *c.ObjectMeta.DeepCopy(), } } func TestNewObjectReference_ReflectionFallback(t *testing.T) { // Test that when object type is not in scheme, reflection is used to get Kind obj := &customObject{ ObjectMeta: metav1.ObjectMeta{ Name: "custom-resource", Namespace: "custom-ns", UID: "custom-uid-123", }, } ref := NewObjectReference(obj, "custom") // Kind should be derived from reflection (struct name) require.Equal(t, "customObject", ref.Kind) // APIVersion will be empty since it's not in scheme require.Empty(t, ref.ApiVersion) require.Equal(t, "custom-ns", ref.Namespace) require.Equal(t, "custom-resource", ref.Name) require.Equal(t, "custom-uid-123", string(ref.UID)) require.Equal(t, "custom", ref.Source) } ================================================ FILE: pkg/http/drain.go ================================================ /* Copyright 2026 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 http import ( "io" ) const ( // drainMaxBytes caps how much of a response body we drain to return the // connection to the pool. A buggy or adversarial server could stream an // unbounded body; reading it all would block indefinitely and waste memory. // On success paths the JSON decoder has already consumed the payload before // the deferred DrainAndClose runs, so only trailing bytes remain. On error paths // the body is typically a short error message. 1 MiB is generous for either case. drainMaxBytes = 1 << 20 // 1 MiB ) // DrainAndClose drains up to drainMaxBytes of the response body before // closing it so the underlying TCP connection can be reused by the HTTP // client's connection pool. Bytes beyond the cap are left unread; the // connection will be discarded rather than pooled in that case, which is // acceptable for oversized or malformed responses. func DrainAndClose(body io.ReadCloser) { _, _ = io.Copy(io.Discard, io.LimitReader(body, drainMaxBytes)) _ = body.Close() } ================================================ FILE: pkg/http/drain_test.go ================================================ /* Copyright 2026 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 http import ( "bytes" "io" "strings" "testing" "github.com/stretchr/testify/assert" ) type trackingReadCloser struct { io.Reader closed bool } func (t *trackingReadCloser) Close() error { t.closed = true return nil } func TestDrainAndClose_DrainsThenCloses(t *testing.T) { rc := &trackingReadCloser{Reader: strings.NewReader("remaining body data")} DrainAndClose(rc) assert.True(t, rc.closed) // Confirm body is fully drained: reader should be at EOF. n, err := rc.Read(make([]byte, 1)) assert.Equal(t, 0, n) assert.ErrorIs(t, err, io.EOF) } func TestDrainAndClose_EmptyBody(t *testing.T) { rc := &trackingReadCloser{Reader: strings.NewReader("")} DrainAndClose(rc) assert.True(t, rc.closed) } func TestDrainAndClose_OversizedBody(t *testing.T) { // Body is 1 byte larger than the cap; the excess byte must remain unread so // the connection is discarded rather than pooled — but Close must still be called. oversized := bytes.Repeat([]byte("x"), drainMaxBytes+1) rc := &trackingReadCloser{Reader: bytes.NewReader(oversized)} DrainAndClose(rc) assert.True(t, rc.closed) // Exactly one byte should remain after the capped drain. remaining, err := io.ReadAll(rc.Reader) assert.NoError(t, err) assert.Len(t, remaining, 1, "expected exactly 1 byte past the drain cap to remain unread") } ================================================ FILE: pkg/http/http.go ================================================ /* 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. */ // ref: https://github.com/linki/instrumented_http/blob/master/client.go package http import ( "fmt" "net/http" "time" "github.com/prometheus/client_golang/prometheus" "sigs.k8s.io/external-dns/pkg/metrics" ) var ( RequestDurationMetric = metrics.NewSummaryVecWithOpts( prometheus.SummaryOpts{ Name: "request_duration_seconds", Help: "The HTTP request latencies in seconds.", Subsystem: "http", ConstLabels: prometheus.Labels{"handler": "instrumented_http"}, Objectives: map[float64]float64{0.5: 0.05, 0.9: 0.01, 0.99: 0.001}, }, []string{metrics.LabelScheme, metrics.LabelHost, metrics.LabelPath, metrics.LabelMethod, metrics.LabelStatus}, ) ) func init() { metrics.RegisterMetric.MustRegister(RequestDurationMetric) } type CustomRoundTripper struct { next http.RoundTripper } // CancelRequest is a no-op to satisfy interfaces that require it. // https://github.com/kubernetes/client-go/blob/34f52c14eaed7e50c845cc14e85e1c4c91e77470/transport/transport.go#L346 func (r *CustomRoundTripper) CancelRequest(_ *http.Request) { } func (r *CustomRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { start := time.Now() resp, err := r.next.RoundTrip(req) status := "" if resp != nil { status = fmt.Sprintf("%d", resp.StatusCode) } RequestDurationMetric.SetWithLabels(time.Since(start).Seconds(), metrics.Labels{ metrics.LabelScheme: req.URL.Scheme, metrics.LabelHost: req.URL.Host, metrics.LabelPath: metrics.PathProcessor(req.URL.Path), metrics.LabelMethod: req.Method, metrics.LabelStatus: status, }) return resp, err } func NewInstrumentedClient(next *http.Client) *http.Client { if next == nil { next = http.DefaultClient } next.Transport = NewInstrumentedTransport(next.Transport) return next } func NewInstrumentedTransport(next http.RoundTripper) http.RoundTripper { if next == nil { next = http.DefaultTransport } return &CustomRoundTripper{next: next} } ================================================ FILE: pkg/http/http_benchmark_test.go ================================================ /* 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 http import ( "bytes" "io" "net/http" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type roundTripFunc func(req *http.Request) *http.Response func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req), nil } // newTestClient returns *http.client with Transport replaced to avoid making real calls func newTestClient(fn roundTripFunc) *http.Client { return &http.Client{ Transport: NewInstrumentedTransport(fn), } } type apiUnderTest struct { client *http.Client baseURL string } func (api *apiUnderTest) doStuff() ([]byte, error) { resp, err := api.client.Get(api.baseURL + "/some/path") if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) } func BenchmarkRoundTripper(b *testing.B) { client := newTestClient(func(_ *http.Request) *http.Response { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`OK`)), Header: make(http.Header), } }) for b.Loop() { api := apiUnderTest{client, "http://example.com"} body, err := api.doStuff() require.NoError(b, err) assert.Equal(b, []byte("OK"), body) } } func TestRoundTripper_Concurrent(t *testing.T) { client := newTestClient(func(_ *http.Request) *http.Response { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`OK`)), Header: make(http.Header), } }) api := &apiUnderTest{client: client, baseURL: "http://example.com"} const numGoroutines = 100 var wg sync.WaitGroup wg.Add(numGoroutines) for range numGoroutines { go func() { defer wg.Done() body, err := api.doStuff() assert.NoError(t, err) assert.Equal(t, []byte("OK"), body) }() } wg.Wait() } ================================================ FILE: pkg/http/http_test.go ================================================ /* 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 http import ( "fmt" "io" "net/http" "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type dummyTransport struct{} func (d *dummyTransport) RoundTrip(_ *http.Request) (*http.Response, error) { return nil, fmt.Errorf("dummy error") } func TestNewInstrumentedTransport(t *testing.T) { dt := &dummyTransport{} rt := NewInstrumentedTransport(dt) crt, ok := rt.(*CustomRoundTripper) require.True(t, ok) require.Equal(t, dt, crt.next) // Should default to http.DefaultTransport if nil rt2 := NewInstrumentedTransport(nil) crt2, ok := rt2.(*CustomRoundTripper) require.True(t, ok) require.Equal(t, http.DefaultTransport, crt2.next) } func TestNewInstrumentedClient(t *testing.T) { client := &http.Client{Transport: &dummyTransport{}} result := NewInstrumentedClient(client) require.Equal(t, client, result) _, ok := result.Transport.(*CustomRoundTripper) require.True(t, ok) // Should default to http.DefaultClient if nil result2 := NewInstrumentedClient(nil) require.Equal(t, http.DefaultClient, result2) _, ok = result2.Transport.(*CustomRoundTripper) require.True(t, ok) } func TestCancelRequest(t *testing.T) { for _, tt := range []struct { title string customRoundTripper CustomRoundTripper request *http.Request }{ { title: "CancelRequest does nothing", customRoundTripper: CustomRoundTripper{}, request: &http.Request{}, }, } { t.Run(tt.title, func(_ *testing.T) { tt.customRoundTripper.CancelRequest(tt.request) }) } } type mockRoundTripper struct { response *http.Response error error } func (mrt mockRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { return mrt.response, mrt.error } func TestRoundTrip(t *testing.T) { for _, tt := range []struct { title string nextRoundTripper mockRoundTripper request *http.Request method string url string body io.Reader expectError bool expectedResponse *http.Response }{ { title: "RoundTrip returns no error", nextRoundTripper: mockRoundTripper{}, request: &http.Request{ Method: http.MethodGet, URL: &url.URL{ Scheme: "HTTPS", Host: "test.local", Path: "/path", }, Body: nil, }, expectError: false, expectedResponse: nil, }, { title: "RoundTrip extracts status from request", nextRoundTripper: mockRoundTripper{ response: &http.Response{ StatusCode: http.StatusOK, }, }, request: &http.Request{ Method: http.MethodGet, URL: &url.URL{ Scheme: "HTTPS", Host: "test.local", Path: "/path", }, Body: nil, }, expectError: false, expectedResponse: &http.Response{ StatusCode: http.StatusOK, }, }, } { t.Run(tt.title, func(t *testing.T) { req, err := http.NewRequest(tt.method, tt.url, tt.body) customRoundTripper := CustomRoundTripper{ next: tt.nextRoundTripper, } resp, err := customRoundTripper.RoundTrip(req) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } assert.Equal(t, tt.expectedResponse, resp) }) } } ================================================ FILE: pkg/metrics/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - metrics ================================================ FILE: pkg/metrics/labels.go ================================================ /* 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 metrics import ( "github.com/prometheus/client_golang/prometheus" ) const ( LabelScheme = "scheme" LabelHost = "host" LabelPath = "path" LabelMethod = "method" LabelStatus = "status" ) type Labels = prometheus.Labels ================================================ FILE: pkg/metrics/metrics.go ================================================ /* 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 metrics import ( "fmt" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/version" log "github.com/sirupsen/logrus" cfg "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) const ( Namespace = "external_dns" ) var ( RegisterMetric = NewMetricsRegister() ) func init() { RegisterMetric.MustRegister(NewGaugeFuncMetric(prometheus.GaugeOpts{ Namespace: Namespace, Name: "build_info", Help: fmt.Sprintf( "A metric with a constant '1' value labeled with 'version' and 'revision' of %s and the 'go_version', 'os' and the 'arch' used the build.", Namespace, ), ConstLabels: prometheus.Labels{ "version": cfg.Version, "revision": version.GetRevision(), "go_version": version.GoVersion, "os": version.GoOS, "arch": version.GoArch, }, })) } func NewMetricsRegister() *MetricRegistry { reg := prometheus.WrapRegistererWith( prometheus.Labels{}, prometheus.DefaultRegisterer) return &MetricRegistry{ Registerer: reg, Metrics: []*Metric{}, mName: make(map[string]bool), } } // MustRegister registers a metric if it hasn't been registered yet. // // Usage: MustRegister(...) // Example: // // func init() { // metrics.RegisterMetric.MustRegister(errorsTotal) // } func (m *MetricRegistry) MustRegister(cs IMetric) { switch v := cs.(type) { case CounterMetric, GaugeMetric, SummaryVecMetric, CounterVecMetric, GaugeVecMetric, GaugeFuncMetric: if _, exists := m.mName[cs.Get().FQDN]; exists { return } else { m.mName[cs.Get().FQDN] = true } m.Metrics = append(m.Metrics, cs.Get()) switch metric := v.(type) { case CounterMetric: m.Registerer.MustRegister(metric.Counter) case GaugeMetric: m.Registerer.MustRegister(metric.Gauge) case SummaryVecMetric: m.Registerer.MustRegister(metric.SummaryVec) case GaugeVecMetric: m.Registerer.MustRegister(metric.Gauge) case CounterVecMetric: m.Registerer.MustRegister(metric.CounterVec) case GaugeFuncMetric: m.Registerer.MustRegister(metric.GaugeFunc) } log.Debugf("Register metric: %s", cs.Get().FQDN) default: log.Warnf("Unsupported metric type: %T", v) return } } ================================================ FILE: pkg/metrics/metrics_test.go ================================================ /* 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 metrics import ( "testing" "github.com/prometheus/client_golang/prometheus" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) type MockMetric struct { FQDN string } func (m *MockMetric) Get() *Metric { return &Metric{FQDN: m.FQDN} } func TestMustRegister(t *testing.T) { tests := []struct { name string metrics []IMetric expected int }{ { name: "single metric", metrics: []IMetric{ NewCounterWithOpts(prometheus.CounterOpts{Name: "test_counter_1"}), }, expected: 1, }, { name: "two metrics", metrics: []IMetric{ NewGaugeWithOpts(prometheus.GaugeOpts{Name: "test_gauge_2", Subsystem: "test"}), NewCounterWithOpts(prometheus.CounterOpts{Name: "test_counter_2", Subsystem: "app"}), }, expected: 2, }, { name: "mix of metrics", metrics: []IMetric{ NewGaugeWithOpts(prometheus.GaugeOpts{Name: "test_gauge_3"}), NewCounterWithOpts(prometheus.CounterOpts{Name: "test_counter_3"}), NewCounterVecWithOpts(prometheus.CounterOpts{Name: "test_counter_vec_3"}, []string{"label"}), NewGaugedVectorOpts(prometheus.GaugeOpts{Name: "test_gauge_v_3"}, []string{"label"}), NewSummaryVecWithOpts(prometheus.SummaryOpts{Name: "test_summary_v_3"}, []string{"label"}), }, expected: 5, }, { name: "unsupported metric", metrics: []IMetric{ &MockMetric{FQDN: "unsupported_metric"}, }, expected: 0, }, { name: "skip if metric exists", metrics: []IMetric{ NewGaugeWithOpts(prometheus.GaugeOpts{Name: "existing_metric"}), NewGaugeWithOpts(prometheus.GaugeOpts{Name: "existing_metric"}), }, expected: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { registry := NewMetricsRegister() for _, m := range tt.metrics { registry.MustRegister(m) } assert.Len(t, registry.Metrics, tt.expected) }) } } func TestUnsupportedMetricWarning(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) registry := NewMetricsRegister() mockUnsupported := &MockMetric{FQDN: "unsupported_metric"} registry.MustRegister(mockUnsupported) assert.NotContains(t, registry.mName, "unsupported_metric") logtest.TestHelperLogContains("Unsupported metric type: *metrics.MockMetric", hook, t) } func TestNewMetricsRegister(t *testing.T) { registry := NewMetricsRegister() assert.NotNil(t, registry) assert.NotNil(t, registry.Registerer) assert.Empty(t, registry.Metrics) assert.Empty(t, registry.mName) } ================================================ FILE: pkg/metrics/models.go ================================================ /* 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 metrics import ( "fmt" "strings" "github.com/prometheus/client_golang/prometheus" ) type MetricRegistry struct { Registerer prometheus.Registerer Metrics []*Metric mName map[string]bool } type Metric struct { Type string Namespace string Subsystem string Name string Help string FQDN string } type IMetric interface { Get() *Metric } type GaugeMetric struct { Metric Gauge prometheus.Gauge } func (g GaugeMetric) Get() *Metric { return &g.Metric } type CounterMetric struct { Metric Counter prometheus.Counter } func (g CounterMetric) Get() *Metric { return &g.Metric } type CounterVecMetric struct { Metric CounterVec *prometheus.CounterVec } func (g CounterVecMetric) Get() *Metric { return &g.Metric } type GaugeVecMetric struct { Metric Gauge prometheus.GaugeVec } func (g GaugeVecMetric) Get() *Metric { return &g.Metric } // SetWithLabels sets the value of the Gauge metric for the specified label values. // All label values are converted to lowercase before being applied. func (g GaugeVecMetric) SetWithLabels(value float64, lvs ...string) { g.Gauge.WithLabelValues(toLower(lvs)...).Set(value) } // AddWithLabels adds the value to the Gauge metric for the specified label values. // All label values are converted to lowercase before being applied. // // Without Reset(), values accumulate and reset only on process restart. // Use Reset() + AddWithLabels() pattern for per-cycle counts. func (g GaugeVecMetric) AddWithLabels(value float64, lvs ...string) { g.Gauge.WithLabelValues(toLower(lvs)...).Add(value) } func NewGaugeWithOpts(opts prometheus.GaugeOpts) GaugeMetric { opts.Namespace = Namespace return GaugeMetric{ Metric: Metric{ Type: "gauge", Name: opts.Name, FQDN: fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name), Namespace: opts.Namespace, Subsystem: opts.Subsystem, Help: opts.Help, }, Gauge: prometheus.NewGauge(opts), } } // NewGaugedVectorOpts creates a new GaugeVec based on the provided GaugeOpts and // partitioned by the given label names. func NewGaugedVectorOpts(opts prometheus.GaugeOpts, labelNames []string) GaugeVecMetric { opts.Namespace = Namespace return GaugeVecMetric{ Metric: Metric{ Type: "gauge", Name: opts.Name, FQDN: fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name), Namespace: opts.Namespace, Subsystem: opts.Subsystem, Help: opts.Help, }, Gauge: *prometheus.NewGaugeVec(opts, labelNames), } } func NewCounterWithOpts(opts prometheus.CounterOpts) CounterMetric { opts.Namespace = Namespace return CounterMetric{ Metric: Metric{ Type: "counter", Name: opts.Name, FQDN: fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name), Namespace: opts.Namespace, Subsystem: opts.Subsystem, Help: opts.Help, }, Counter: prometheus.NewCounter(opts), } } func NewCounterVecWithOpts(opts prometheus.CounterOpts, labelNames []string) CounterVecMetric { opts.Namespace = Namespace return CounterVecMetric{ Metric: Metric{ Type: "counter", Name: opts.Name, FQDN: fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name), Namespace: opts.Namespace, Subsystem: opts.Subsystem, Help: opts.Help, }, CounterVec: prometheus.NewCounterVec(opts, labelNames), } } type GaugeFuncMetric struct { Metric GaugeFunc prometheus.GaugeFunc } func (g GaugeFuncMetric) Get() *Metric { return &g.Metric } func NewGaugeFuncMetric(opts prometheus.GaugeOpts) GaugeFuncMetric { return GaugeFuncMetric{ Metric: Metric{ Type: "gauge", Name: opts.Name, FQDN: func() string { if opts.Subsystem != "" { return fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name) } return opts.Name }(), Namespace: opts.Namespace, Subsystem: opts.Subsystem, Help: opts.Help, }, GaugeFunc: prometheus.NewGaugeFunc(opts, func() float64 { return 1 }), } } type SummaryVecMetric struct { Metric SummaryVec prometheus.SummaryVec } func (s SummaryVecMetric) Get() *Metric { return &s.Metric } func (s SummaryVecMetric) SetWithLabels(value float64, labels prometheus.Labels) { s.SummaryVec.With(labels).Observe(value) } func NewSummaryVecWithOpts(opts prometheus.SummaryOpts, labels []string) SummaryVecMetric { opts.Namespace = Namespace return SummaryVecMetric{ Metric: Metric{ Type: "summaryVec", Name: opts.Name, FQDN: fmt.Sprintf("%s_%s", opts.Subsystem, opts.Name), Namespace: opts.Namespace, Subsystem: opts.Subsystem, Help: opts.Help, }, SummaryVec: *prometheus.NewSummaryVec(opts, labels), } } func PathProcessor(path string) string { parts := strings.Split(path, "/") return parts[len(parts)-1] } // toLower converts all label values to lowercase. // The Prometheus maintainers have intentionally avoided magic transformations to keep label handling explicit and predictable. // We expect consistent casing, normalizing at ingestion is the standard practice. func toLower(lvs []string) []string { for i := range lvs { lvs[i] = strings.ToLower(lvs[i]) } return lvs } ================================================ FILE: pkg/metrics/models_test.go ================================================ /* 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 metrics import ( "reflect" "runtime" "testing" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewGaugeWithOpts(t *testing.T) { opts := prometheus.GaugeOpts{ Name: "test_gauge", Subsystem: "test_subsystem", Help: "This is a test gauge", } gaugeMetric := NewGaugeWithOpts(opts) assert.Equal(t, "gauge", gaugeMetric.Type) assert.Equal(t, "test_gauge", gaugeMetric.Name) assert.Equal(t, Namespace, gaugeMetric.Namespace) assert.Equal(t, "test_subsystem", gaugeMetric.Subsystem) assert.Equal(t, "This is a test gauge", gaugeMetric.Help) assert.Equal(t, "test_subsystem_test_gauge", gaugeMetric.FQDN) assert.NotNil(t, gaugeMetric.Gauge) } func TestNewCounterWithOpts(t *testing.T) { opts := prometheus.CounterOpts{ Name: "test_counter", Subsystem: "test_subsystem", Help: "This is a test counter", } counterMetric := NewCounterWithOpts(opts) assert.Equal(t, "counter", counterMetric.Type) assert.Equal(t, "test_counter", counterMetric.Name) assert.Equal(t, Namespace, counterMetric.Namespace) assert.Equal(t, "test_subsystem", counterMetric.Subsystem) assert.Equal(t, "This is a test counter", counterMetric.Help) assert.Equal(t, "test_subsystem_test_counter", counterMetric.FQDN) assert.NotNil(t, counterMetric.Counter) } func TestNewCounterVecWithOpts(t *testing.T) { opts := prometheus.CounterOpts{ Name: "test_counter_vec", Namespace: "test_namespace", Subsystem: "test_subsystem", Help: "This is a test counter vector", } labelNames := []string{"label1", "label2"} counterVecMetric := NewCounterVecWithOpts(opts, labelNames) assert.Equal(t, "counter", counterVecMetric.Type) assert.Equal(t, "test_counter_vec", counterVecMetric.Name) assert.Equal(t, Namespace, counterVecMetric.Namespace) assert.Equal(t, "test_subsystem", counterVecMetric.Subsystem) assert.Equal(t, "This is a test counter vector", counterVecMetric.Help) assert.Equal(t, "test_subsystem_test_counter_vec", counterVecMetric.FQDN) assert.NotNil(t, counterVecMetric.CounterVec) } func TestGaugeV_SetWithLabels(t *testing.T) { opts := prometheus.GaugeOpts{ Name: "test_gauge", Namespace: "test_ns", Subsystem: "test_sub", Help: "help text", } gv := NewGaugedVectorOpts(opts, []string{"label1", "label2"}) gv.SetWithLabels(1.23, "Alpha", "BETA") g, err := gv.Gauge.GetMetricWithLabelValues("alpha", "beta") assert.NoError(t, err) var m dto.Metric err = g.Write(&m) assert.NoError(t, err) assert.NotNil(t, m.Gauge) assert.InDelta(t, 1.23, *m.Gauge.Value, 0.01) // Override the value gv.SetWithLabels(4.56, "ALPHA", "beta") // reuse g (same label combination) err = g.Write(&m) assert.NoError(t, err) assert.InDelta(t, 4.56, *m.Gauge.Value, 0.01) assert.Len(t, m.Label, 2) } func TestNewGaugeFuncMetric(t *testing.T) { tests := []struct { name string metricName string subSystem string constLabels prometheus.Labels expectedFqName string expectedDescString string expectedGaugeFuncReturn float64 }{ { name: "NewGaugeFuncMetric returns build_info", metricName: "build_info", subSystem: "", constLabels: prometheus.Labels{ "version": "0.0.1", "goversion": runtime.Version(), "arch": "arm64", }, expectedFqName: "external_dns_build_info", expectedDescString: "version=\"0.0.1\"", expectedGaugeFuncReturn: 1, }, { name: "NewGaugeFuncMetric subsystem alters name", metricName: "metricName", subSystem: "subSystem", constLabels: prometheus.Labels{}, expectedFqName: "external_dns_subSystem_metricName", expectedDescString: "", expectedGaugeFuncReturn: 1, }, { name: "NewGaugeFuncMetric GaugeFunc returns 1", metricName: "metricName", subSystem: "", constLabels: prometheus.Labels{}, expectedFqName: "external_dns_metricName", expectedDescString: "", expectedGaugeFuncReturn: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { metric := NewGaugeFuncMetric(prometheus.GaugeOpts{ Namespace: Namespace, Name: tt.metricName, Subsystem: tt.subSystem, ConstLabels: tt.constLabels, }) desc := metric.GaugeFunc.Desc() assert.Equal(t, tt.expectedFqName, reflect.ValueOf(desc).Elem().FieldByName("fqName").String()) assert.Contains(t, desc.String(), tt.expectedDescString) testRegistry := prometheus.NewRegistry() err := testRegistry.Register(metric.GaugeFunc) require.NoError(t, err) metricFamily, err := testRegistry.Gather() require.NoError(t, err) require.Len(t, metricFamily, 1) require.NotNil(t, metricFamily[0].Metric[0].Gauge) assert.InDelta(t, tt.expectedGaugeFuncReturn, metricFamily[0].Metric[0].GetGauge().GetValue(), 0.0001) }) } } func TestSummaryV_SetWithLabels(t *testing.T) { opts := prometheus.SummaryOpts{ Name: "test_summaryVec", Namespace: "test_ns", Subsystem: "test_sub", Help: "help text", } labels := Labels{} sv := NewSummaryVecWithOpts(opts, []string{"label1", "label2"}) labels["label1"] = "alpha" labels["label2"] = "beta" sv.SetWithLabels(5.01, labels) reg := prometheus.NewRegistry() reg.MustRegister(sv.SummaryVec) metricsFamilies, err := reg.Gather() assert.NoError(t, err) assert.Len(t, metricsFamilies, 1) s, err := sv.SummaryVec.GetMetricWithLabelValues("alpha", "beta") assert.NoError(t, err) metricsFamilies, err = reg.Gather() s.Observe(5.21) metricsFamilies, err = reg.Gather() assert.NoError(t, err) assert.InDelta(t, 10.22, *metricsFamilies[0].Metric[0].Summary.SampleSum, 0.01) assert.Len(t, metricsFamilies[0].Metric[0].Label, 2) } func TestPathProcessor(t *testing.T) { tests := []struct { input string expected string }{ {"/foo/bar", "bar"}, {"/foo/", ""}, {"/", ""}, {"", ""}, {"/foo/bar/baz", "baz"}, {"foo/bar", "bar"}, {"foo", "foo"}, {"foo/", ""}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { require.Equal(t, tt.expected, PathProcessor(tt.input)) }) } } func TestGaugeV_AddWithLabels(t *testing.T) { opts := prometheus.GaugeOpts{ Name: "test_gauge_add", Namespace: "test_ns", Subsystem: "test_sub", Help: "help text", } gv := NewGaugedVectorOpts(opts, []string{"label1", "label2"}) // Add with mixed case labels - should be lowercased gv.AddWithLabels(1.0, "Alpha", "BETA") g, err := gv.Gauge.GetMetricWithLabelValues("alpha", "beta") assert.NoError(t, err) var m dto.Metric err = g.Write(&m) assert.NoError(t, err) assert.NotNil(t, m.Gauge) assert.InDelta(t, 1.0, *m.Gauge.Value, 0.01) // Add again - should increment, not override gv.AddWithLabels(2.0, "ALPHA", "beta") err = g.Write(&m) assert.NoError(t, err) assert.InDelta(t, 3.0, *m.Gauge.Value, 0.01) // 1.0 + 2.0 = 3.0 // Add one more time gv.AddWithLabels(0.5, "alpha", "Beta") err = g.Write(&m) assert.NoError(t, err) assert.InDelta(t, 3.5, *m.Gauge.Value, 0.01) // 3.0 + 0.5 = 3.5 assert.Len(t, m.Label, 2) } ================================================ FILE: pkg/rfc2317/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - rfc2317 ================================================ FILE: pkg/rfc2317/arpa.go ================================================ /* Copyright 2023 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 rfc2317 import ( "fmt" "net" "strconv" "strings" ) // CidrToInAddr converts a CIDR block into its reverse lookup (in-addr) name. // Given "2001::/16" returns "1.0.0.2.ip6.arpa" // Given "10.20.30.0/24" returns "30.20.10.in-addr.arpa" // Given "10.20.30.0/25" returns "0/25.30.20.10.in-addr.arpa" (RFC2317) func CidrToInAddr(cidr string) (string, error) { // If the user sent an IP instead of a CIDR (i.e. no "/"), turn it // into a CIDR by adding /32 or /128 as appropriate. ip := net.ParseIP(cidr) if ip != nil { if ip.To4() != nil { cidr = ip.String() + "/32" // Older code used `cidr + "/32"` but that didn't work with // "IPv4 mapped IPv6 address". ip.String() returns the IPv4 // address for all IPv4 addresses no matter how they are // expressed internally. } else { cidr += "/128" } } a, c, err := net.ParseCIDR(cidr) if err != nil { return "", err } base, err := reverseaddr(a.String()) if err != nil { return "", err } base = strings.TrimRight(base, ".") if !a.Equal(c.IP) { return "", fmt.Errorf("CIDR %v has 1 bits beyond the mask", cidr) } bits, total := c.Mask.Size() var toTrim int if bits == 0 { return "", fmt.Errorf("cannot use /0 in reverse CIDR") } // Handle IPv4 "Classless in-addr.arpa delegation" RFC2317: if total == 32 && bits >= 25 && bits < 32 { // first address / netmask . Class-b-arpa. fparts := strings.Split(c.IP.String(), ".") first := fparts[3] bparts := strings.SplitN(base, ".", 2) return fmt.Sprintf("%s/%d.%s", first, bits, bparts[1]), nil } // Handle IPv4 Class-full and IPv6: switch total { case 32: if bits%8 != 0 { return "", fmt.Errorf("IPv4 mask must be multiple of 8 bits") } toTrim = (total - bits) / 8 case 128: if bits%4 != 0 { return "", fmt.Errorf("IPv6 mask must be multiple of 4 bits") } toTrim = (total - bits) / 4 default: return "", fmt.Errorf("invalid address (not IPv4 or IPv6): %v", cidr) } parts := strings.SplitN(base, ".", toTrim+1) return parts[len(parts)-1], nil } // copied from go source. // https://github.com/golang/go/blob/38b2c06e144c6ea7087c575c76c66e41265ae0b7/src/net/dnsclient.go#L26C1-L51C1 // The go source does not export this function so we copy it here. // reverseaddr returns the in-addr.arpa. or ip6.arpa. hostname of the IP // address addr suitable for rDNS (PTR) record lookup or an error if it fails // to parse the IP address. func reverseaddr(addr string) (string, error) { ip := net.ParseIP(addr) if ip == nil { return "", &net.DNSError{Err: "unrecognized address", Name: addr} } if ip.To4() != nil { return Uitoa(uint(ip[15])) + "." + Uitoa(uint(ip[14])) + "." + Uitoa(uint(ip[13])) + "." + Uitoa(uint(ip[12])) + ".in-addr.arpa.", nil } // Must be IPv6 buf := make([]byte, 0, len(ip)*4+len("ip6.arpa.")) // Add it, in reverse, to the buffer for i := len(ip) - 1; i >= 0; i-- { v := ip[i] buf = append(buf, hexDigit[v&0xF], '.', hexDigit[v>>4], '.') } // Append "ip6.arpa." and return (buf already has the final .) buf = append(buf, "ip6.arpa."...) return string(buf), nil } const hexDigit = "0123456789abcdef" func Uitoa(val uint) string { return strconv.FormatInt(int64(val), 10) } ================================================ FILE: pkg/rfc2317/arpa_test.go ================================================ /* Copyright 2023 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 rfc2317 import ( "fmt" "testing" ) func TestCidrToInAddr(t *testing.T) { var tests = []struct { in string out string errmsg string }{ {"174.136.107.0/24", "107.136.174.in-addr.arpa", ""}, {"174.136.107.1/24", "107.136.174.in-addr.arpa", "CIDR 174.136.107.1/24 has 1 bits beyond the mask"}, {"174.136.0.0/16", "136.174.in-addr.arpa", ""}, {"174.136.43.0/16", "136.174.in-addr.arpa", "CIDR 174.136.43.0/16 has 1 bits beyond the mask"}, {"174.0.0.0/8", "174.in-addr.arpa", ""}, {"174.136.43.0/8", "174.in-addr.arpa", "CIDR 174.136.43.0/8 has 1 bits beyond the mask"}, {"174.136.0.44/8", "174.in-addr.arpa", "CIDR 174.136.0.44/8 has 1 bits beyond the mask"}, {"174.136.45.45/8", "174.in-addr.arpa", "CIDR 174.136.45.45/8 has 1 bits beyond the mask"}, {"2001::/16", "1.0.0.2.ip6.arpa", ""}, {"2001:0db8:0123:4567:89ab:cdef:1234:5670/124", "7.6.5.4.3.2.1.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.8.b.d.0.1.0.0.2.ip6.arpa", ""}, {"174.136.107.14/32", "14.107.136.174.in-addr.arpa", ""}, {"2001:0db8:0123:4567:89ab:cdef:1234:5678/128", "8.7.6.5.4.3.2.1.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.8.b.d.0.1.0.0.2.ip6.arpa", ""}, // IPv4 "Classless in-addr.arpa delegation" RFC2317. // From examples in the RFC: {"192.0.2.0/25", "0/25.2.0.192.in-addr.arpa", ""}, {"192.0.2.128/26", "128/26.2.0.192.in-addr.arpa", ""}, {"192.0.2.192/26", "192/26.2.0.192.in-addr.arpa", ""}, // All the base cases: {"174.1.0.0/25", "0/25.0.1.174.in-addr.arpa", ""}, {"174.1.0.0/26", "0/26.0.1.174.in-addr.arpa", ""}, {"174.1.0.0/27", "0/27.0.1.174.in-addr.arpa", ""}, {"174.1.0.0/28", "0/28.0.1.174.in-addr.arpa", ""}, {"174.1.0.0/29", "0/29.0.1.174.in-addr.arpa", ""}, {"174.1.0.0/30", "0/30.0.1.174.in-addr.arpa", ""}, {"174.1.0.0/31", "0/31.0.1.174.in-addr.arpa", ""}, // /25 (all cases) {"174.1.0.0/25", "0/25.0.1.174.in-addr.arpa", ""}, {"174.1.0.128/25", "128/25.0.1.174.in-addr.arpa", ""}, // /26 (all cases) {"174.1.0.0/26", "0/26.0.1.174.in-addr.arpa", ""}, {"174.1.0.64/26", "64/26.0.1.174.in-addr.arpa", ""}, {"174.1.0.128/26", "128/26.0.1.174.in-addr.arpa", ""}, {"174.1.0.192/26", "192/26.0.1.174.in-addr.arpa", ""}, // /27 (all cases) {"174.1.0.0/27", "0/27.0.1.174.in-addr.arpa", ""}, {"174.1.0.32/27", "32/27.0.1.174.in-addr.arpa", ""}, {"174.1.0.64/27", "64/27.0.1.174.in-addr.arpa", ""}, {"174.1.0.96/27", "96/27.0.1.174.in-addr.arpa", ""}, {"174.1.0.128/27", "128/27.0.1.174.in-addr.arpa", ""}, {"174.1.0.160/27", "160/27.0.1.174.in-addr.arpa", ""}, {"174.1.0.192/27", "192/27.0.1.174.in-addr.arpa", ""}, {"174.1.0.224/27", "224/27.0.1.174.in-addr.arpa", ""}, // /28 (first 2, last 2) {"174.1.0.0/28", "0/28.0.1.174.in-addr.arpa", ""}, {"174.1.0.16/28", "16/28.0.1.174.in-addr.arpa", ""}, {"174.1.0.224/28", "224/28.0.1.174.in-addr.arpa", ""}, {"174.1.0.240/28", "240/28.0.1.174.in-addr.arpa", ""}, // /29 (first 2 cases) {"174.1.0.0/29", "0/29.0.1.174.in-addr.arpa", ""}, {"174.1.0.8/29", "8/29.0.1.174.in-addr.arpa", ""}, // /30 (first 2 cases) {"174.1.0.0/30", "0/30.0.1.174.in-addr.arpa", ""}, {"174.1.0.4/30", "4/30.0.1.174.in-addr.arpa", ""}, // /31 (first 2 cases) {"174.1.0.0/31", "0/31.0.1.174.in-addr.arpa", ""}, {"174.1.0.2/31", "2/31.0.1.174.in-addr.arpa", ""}, // IPv4-mapped IPv6 addresses: {"::ffff:174.136.107.15", "15.107.136.174.in-addr.arpa", ""}, // Error Cases: {"0.0.0.0/0", "", "cannot use /0 in reverse CIDR"}, {"2001::/0", "", "CIDR 2001::/0 has 1 bits beyond the mask"}, {"4.5/16", "", "invalid CIDR address: 4.5/16"}, {"foo.com", "", "invalid CIDR address: foo.com"}, } for i, tst := range tests { t.Run(fmt.Sprintf("%d--%s", i, tst.in), func(t *testing.T) { d, err := CidrToInAddr(tst.in) if tst.errmsg == "" { // We DO NOT expect an error. if err != nil { // ...but we got one. t.Errorf("Expected '%s' but got ERROR('%s')", tst.out, err) } else if (tst.errmsg == "") && d != tst.out { // but the expected output was wrong t.Errorf("Expected '%s' but got '%s'", tst.out, d) } } else { // We DO expect an error. if err == nil { // ...but we didn't get one. t.Errorf("Expected ERROR('%s') but got result '%s'", tst.errmsg, d) } else if err.Error() != tst.errmsg { // ...but not the right error. t.Errorf("Expected ERROR('%s') but got ERROR('%s')", tst.errmsg, err) } } }) } } ================================================ FILE: pkg/tlsutils/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - tls ================================================ FILE: pkg/tlsutils/tlsconfig.go ================================================ /* Copyright 2017 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 tlsutils import ( "crypto/tls" "crypto/x509" "errors" "fmt" "os" "strings" ) const ( // The TLS 1.2 default was introduced in Go 1.18 (released March 2022). defaultMinVersion = tls.VersionTLS12 ) // CreateTLSConfig creates tls.Config instance from TLS parameters passed in environment variables with the given prefix func CreateTLSConfig(prefix string) (*tls.Config, error) { caFile := os.Getenv(fmt.Sprintf("%s_CA_FILE", prefix)) certFile := os.Getenv(fmt.Sprintf("%s_CERT_FILE", prefix)) keyFile := os.Getenv(fmt.Sprintf("%s_KEY_FILE", prefix)) serverName := os.Getenv(fmt.Sprintf("%s_TLS_SERVER_NAME", prefix)) isInsecureStr := strings.ToLower(os.Getenv(fmt.Sprintf("%s_TLS_INSECURE", prefix))) isInsecure := isInsecureStr == "true" || isInsecureStr == "yes" || isInsecureStr == "1" return NewTLSConfig(certFile, keyFile, caFile, serverName, isInsecure, defaultMinVersion) } // NewTLSConfig creates a tls.Config instance from directly passed parameters, loading the ca, cert, and key from disk func NewTLSConfig(certPath, keyPath, caPath, serverName string, insecure bool, minVersion uint16) (*tls.Config, error) { if (certPath != "" && keyPath == "") || (certPath == "" && keyPath != "") { return nil, errors.New("either both cert and key or none must be provided") } var certificates []tls.Certificate if certPath != "" { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { return nil, fmt.Errorf("could not load TLS cert: %w", err) } certificates = append(certificates, cert) } // If rootCAs is nil, TLS uses the host's root CA set. var rootCAs *x509.CertPool var err error if caPath != "" { rootCAs, err = loadRoots(caPath) if err != nil { return nil, err } } return &tls.Config{ MinVersion: minVersion, Certificates: certificates, RootCAs: rootCAs, InsecureSkipVerify: insecure, ServerName: serverName, }, nil } // loads CA cert func loadRoots(caPath string) (*x509.CertPool, error) { roots := x509.NewCertPool() pem, err := os.ReadFile(caPath) if err != nil { return nil, fmt.Errorf("error reading %s: %w", caPath, err) } if !roots.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("could not parse PEM certificates from %s", caPath) } return roots, nil } ================================================ FILE: pkg/tlsutils/tlsconfig_test.go ================================================ /* Copyright 2023 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 tlsutils import ( "crypto/tls" "fmt" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var ( rsaCertPEM = `-----BEGIN CERTIFICATE----- MIIB0zCCAX2gAwIBAgIJAI/M7BYjwB+uMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX aWRnaXRzIFB0eSBMdGQwHhcNMTIwOTEyMjE1MjAyWhcNMTUwOTEyMjE1MjAyWjBF MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANLJ hPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wok/4xIA+ui35/MmNa rtNuC+BdZ1tMuVCPFZcCAwEAAaNQME4wHQYDVR0OBBYEFJvKs8RfJaXTH08W+SGv zQyKn0H8MB8GA1UdIwQYMBaAFJvKs8RfJaXTH08W+SGvzQyKn0H8MAwGA1UdEwQF MAMBAf8wDQYJKoZIhvcNAQEFBQADQQBJlffJHybjDGxRMqaRmDhX0+6v02TUKZsW r5QuVbpQhH6u+0UgcW0jp9QwpxoPTLTWGXEWBBBurxFwiCBhkQ+V -----END CERTIFICATE----- ` rsaKeyPEM = testingKey(`-----BEGIN RSA TESTING KEY----- MIIBOwIBAAJBANLJhPHhITqQbPklG3ibCVxwGMRfp/v4XqhfdQHdcVfHap6NQ5Wo k/4xIA+ui35/MmNartNuC+BdZ1tMuVCPFZcCAwEAAQJAEJ2N+zsR0Xn8/Q6twa4G 6OB1M1WO+k+ztnX/1SvNeWu8D6GImtupLTYgjZcHufykj09jiHmjHx8u8ZZB/o1N MQIhAPW+eyZo7ay3lMz1V01WVjNKK9QSn1MJlb06h/LuYv9FAiEA25WPedKgVyCW SmUwbPw8fnTcpqDWE3yTO3vKcebqMSsCIBF3UmVue8YU3jybC3NxuXq3wNm34R8T xVLHwDXh/6NJAiEAl2oHGGLz64BuAfjKrqwz7qMYr9HCLIe/YsoWq/olzScCIQDi D2lWusoe2/nEqfDVVWGWlyJ7yOmqaVm/iNUN9B2N2g== -----END RSA TESTING KEY----- `) ) func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } func writeTempFile(t *testing.T, dir, name, content, envKey string) { t.Helper() path := filepath.Join(dir, name) require.NoError(t, os.WriteFile(path, []byte(content), 0644)) t.Setenv(envKey, path) } func TestCreateTLSConfig(t *testing.T) { tests := []struct { title string prefix string caFile string certFile string keyFile string isInsecureStr string serverName string assertions func(actual *tls.Config, err error) }{ { "Provide only CA returns error", "prefix", "", rsaCertPEM, "", "", "", func(_ *tls.Config, err error) { assert.Contains(t, err.Error(), "either both cert and key or none must be provided") }, }, { "Invalid cert and key returns error", "prefix", "", "invalid-cert", "invalid-key", "", "", func(_ *tls.Config, err error) { assert.Contains(t, err.Error(), "could not load TLS cert") }, }, { "Valid cert and key return a valid tls.Config with a certificate", "prefix", "", rsaCertPEM, rsaKeyPEM, "", "server-name", func(actual *tls.Config, err error) { require.NoError(t, err) assert.Equal(t, "server-name", actual.ServerName) assert.NotNil(t, actual.Certificates[0]) assert.False(t, actual.InsecureSkipVerify) assert.Equal(t, actual.MinVersion, uint16(defaultMinVersion)) }, }, { "Invalid CA file returns error", "prefix", "invalid-ca-content", "", "", "", "", func(_ *tls.Config, err error) { assert.Error(t, err) assert.Contains(t, err.Error(), "could not parse PEM certificates from") }, }, { "Invalid CA file path returns error", "prefix", "ca-path-does-not-exist", "", "", "", "server-name", func(_ *tls.Config, err error) { assert.Error(t, err) assert.Contains(t, err.Error(), "error reading /path/does/not/exist") }, }, { "Complete config with CA, cert, and key returns valid tls.Config", "prefix", rsaCertPEM, rsaCertPEM, rsaKeyPEM, "", "server-name", func(actual *tls.Config, err error) { require.NoError(t, err) assert.Equal(t, "server-name", actual.ServerName) assert.NotNil(t, actual.Certificates[0]) assert.NotNil(t, actual.RootCAs) assert.False(t, actual.InsecureSkipVerify) }, }, } for _, tc := range tests { t.Run(tc.title, func(t *testing.T) { // setup dir := t.TempDir() if tc.caFile == "ca-path-does-not-exist" { t.Setenv(fmt.Sprintf("%s_CA_FILE", tc.prefix), "/path/does/not/exist") } else if tc.caFile != "" { writeTempFile(t, dir, "caFile", tc.caFile, fmt.Sprintf("%s_CA_FILE", tc.prefix)) } if tc.certFile != "" { writeTempFile(t, dir, "certFile", tc.certFile, fmt.Sprintf("%s_CERT_FILE", tc.prefix)) } if tc.keyFile != "" { writeTempFile(t, dir, "keyFile", tc.keyFile, fmt.Sprintf("%s_KEY_FILE", tc.prefix)) } if tc.serverName != "" { t.Setenv(fmt.Sprintf("%s_TLS_SERVER_NAME", tc.prefix), tc.serverName) } if tc.isInsecureStr != "" { t.Setenv(fmt.Sprintf("%s_TLS_INSECURE", tc.prefix), tc.isInsecureStr) } // test actual, err := CreateTLSConfig(tc.prefix) tc.assertions(actual, err) }) } } ================================================ FILE: plan/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - plan ================================================ FILE: plan/conflict.go ================================================ /* Copyright 2017 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 plan import ( "slices" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" ) // ConflictResolver is used to make a decision in case of two or more different kubernetes resources // are trying to acquire the same DNS name type ConflictResolver interface { ResolveCreate(candidates []*endpoint.Endpoint) *endpoint.Endpoint ResolveUpdate(current *endpoint.Endpoint, candidates []*endpoint.Endpoint) *endpoint.Endpoint ResolveRecordTypes(key planKey, row *planTableRow) map[string]*domainEndpoints } // PerResource allows only one resource to own a given dns name type PerResource struct{} // ResolveCreate is invoked when dns name is not owned by any resource // ResolveCreate takes "minimal" (string comparison of Target) endpoint to acquire the DNS record func (s PerResource) ResolveCreate(candidates []*endpoint.Endpoint) *endpoint.Endpoint { return slices.MinFunc(candidates, compareEndpoints) } // ResolveUpdate is invoked when dns name is already owned by "current" endpoint // ResolveUpdate uses "current" record as base and updates it accordingly with new version of same resource // if it doesn't exist then pick min func (s PerResource) ResolveUpdate(current *endpoint.Endpoint, candidates []*endpoint.Endpoint) *endpoint.Endpoint { currentResource := current.Labels[endpoint.ResourceLabelKey] // resource which has already acquired the DNS slices.SortStableFunc(candidates, compareEndpoints) for _, ep := range candidates { if ep.Labels[endpoint.ResourceLabelKey] == currentResource { return ep } } return s.ResolveCreate(candidates) } // ResolveRecordTypes attempts to detect and resolve record type conflicts in desired // endpoints for a domain. For example if there is more than 1 candidate and at least one // of them is a CNAME. Per [RFC 1034 3.6.2] domains that contain a CNAME can not contain any // other record types. The default policy will prefer A and AAAA record types when a conflict is // detected (consistent with [endpoint.Targets.Less]). // // [RFC 1034 3.6.2]: https://datatracker.ietf.org/doc/html/rfc1034#autoid-15 func (s PerResource) ResolveRecordTypes(key planKey, row *planTableRow) map[string]*domainEndpoints { // no conflicts if only a single desired record type for the domain if len(row.candidates) <= 1 { return row.records } cname, other := false, false for _, c := range row.candidates { if c.RecordType == endpoint.RecordTypeCNAME { cname = true } else { other = true } if cname && other { break } } if !cname || !other { return row.records } // conflict was found: prefer non-CNAME record types, discard CNAME candidates // but keep current CNAME so it can be deleted // TODO: emit metric log.Warnf("Domain %s contains conflicting record type candidates; discarding CNAME record", key.dnsName) records := make(map[string]*domainEndpoints, len(row.records)) for recordType, recs := range row.records { if recordType == endpoint.RecordTypeCNAME { records[recordType] = &domainEndpoints{current: recs.current, candidates: []*endpoint.Endpoint{}} continue } records[recordType] = recs } return records } // compareEndpoints compares two endpoints by their targets for use in sort/min operations. func compareEndpoints(a, b *endpoint.Endpoint) int { if a.Targets.IsLess(b.Targets) { return -1 } if b.Targets.IsLess(a.Targets) { return 1 } return 0 } // TODO: with cross-resource/cross-cluster setup alternative variations of ConflictResolver can be used ================================================ FILE: plan/conflict_test.go ================================================ /* Copyright 2017 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 plan import ( "reflect" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/suite" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/endpoint" ) var _ ConflictResolver = PerResource{} type ResolverSuite struct { // resolvers perResource PerResource // endpoints fooV1Cname *endpoint.Endpoint fooV2Cname *endpoint.Endpoint fooV2CnameDuplicate *endpoint.Endpoint fooA5 *endpoint.Endpoint fooAAAA5 *endpoint.Endpoint bar127A *endpoint.Endpoint bar192A *endpoint.Endpoint bar127AAnother *endpoint.Endpoint legacyBar192A *endpoint.Endpoint // record created in AWS now without resource label suite.Suite } func (suite *ResolverSuite) SetupTest() { suite.perResource = PerResource{} // initialize endpoints used in tests suite.fooV1Cname = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"v1"}, RecordType: "CNAME", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-v1", }, } suite.fooV2Cname = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"v2"}, RecordType: "CNAME", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-v2", }, } suite.fooV2CnameDuplicate = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"v2"}, RecordType: "CNAME", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-v2-duplicate", }, } suite.fooA5 = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-5", }, } suite.fooAAAA5 = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: "AAAA", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-5", }, } suite.bar127A = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-127", }, } suite.bar127AAnother = &endpoint.Endpoint{ // TODO: remove this once we move to multiple targets under same endpoint DNSName: "bar", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-127", }, } suite.bar192A = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-192", }, } suite.legacyBar192A = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", } } func (suite *ResolverSuite) TestStrictResolver() { // test that perResource resolver picks min for create list suite.Equal(suite.bar127A, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.bar127A, suite.bar192A}), "should pick min one") suite.Equal(suite.fooA5, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.fooA5, suite.fooV1Cname}), "should pick min one") suite.Equal(suite.fooV1Cname, suite.perResource.ResolveCreate([]*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname}), "should pick min one") // test that perResource resolver preserves resource if it still exists suite.Equal(suite.bar127AAnother, suite.perResource.ResolveUpdate(suite.bar127A, []*endpoint.Endpoint{suite.bar127AAnother, suite.bar127A}), "should pick min for update when same resource endpoint occurs multiple times (remove after multiple-target support") // TODO:remove this test suite.Equal(suite.bar127A, suite.perResource.ResolveUpdate(suite.bar127A, []*endpoint.Endpoint{suite.bar192A, suite.bar127A}), "should pick existing resource") suite.Equal(suite.fooV2Cname, suite.perResource.ResolveUpdate(suite.fooV2Cname, []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV2CnameDuplicate}), "should pick existing resource even if targets are same") suite.Equal(suite.fooA5, suite.perResource.ResolveUpdate(suite.fooV1Cname, []*endpoint.Endpoint{suite.fooA5, suite.fooV2Cname}), "should pick new if resource was deleted") // should actually get the updated record (note ttl is different) newFooV1Cname := &endpoint.Endpoint{ DNSName: suite.fooV1Cname.DNSName, Targets: suite.fooV1Cname.Targets, Labels: suite.fooV1Cname.Labels, RecordType: suite.fooV1Cname.RecordType, RecordTTL: suite.fooV1Cname.RecordTTL + 1, // ttl is different } suite.Equal(newFooV1Cname, suite.perResource.ResolveUpdate(suite.fooV1Cname, []*endpoint.Endpoint{suite.fooA5, suite.fooV2Cname, newFooV1Cname}), "should actually pick same resource with updates") // legacy record's resource value will not match any candidates resource label // therefore pick minimum again suite.Equal(suite.bar127A, suite.perResource.ResolveUpdate(suite.legacyBar192A, []*endpoint.Endpoint{suite.bar127A, suite.bar192A}), " legacy record's resource value will not match, should pick minimum") } func (suite *ResolverSuite) TestPerResource_ResolveRecordTypes() { type args struct { key planKey row *planTableRow } tests := []struct { name string args args want map[string]*domainEndpoints }{ { name: "no conflict: cname record", args: args{ key: planKey{dnsName: "foo"}, row: &planTableRow{ candidates: []*endpoint.Endpoint{suite.fooV1Cname}, records: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: { candidates: []*endpoint.Endpoint{suite.fooV1Cname}, }, }, }, }, want: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: { candidates: []*endpoint.Endpoint{suite.fooV1Cname}, }, }, }, { name: "no conflict: a record", args: args{ key: planKey{dnsName: "foo"}, row: &planTableRow{ current: []*endpoint.Endpoint{suite.fooA5}, candidates: []*endpoint.Endpoint{suite.fooA5}, records: map[string]*domainEndpoints{ endpoint.RecordTypeA: { current: suite.fooA5, candidates: []*endpoint.Endpoint{suite.fooA5}, }, }, }, }, want: map[string]*domainEndpoints{ endpoint.RecordTypeA: { current: suite.fooA5, candidates: []*endpoint.Endpoint{suite.fooA5}, }, }, }, { name: "no conflict: a and aaaa records", args: args{ key: planKey{dnsName: "foo"}, row: &planTableRow{ candidates: []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA5}, records: map[string]*domainEndpoints{ endpoint.RecordTypeA: { candidates: []*endpoint.Endpoint{suite.fooA5}, }, endpoint.RecordTypeAAAA: { candidates: []*endpoint.Endpoint{suite.fooAAAA5}, }, }, }, }, want: map[string]*domainEndpoints{ endpoint.RecordTypeA: { candidates: []*endpoint.Endpoint{suite.fooA5}, }, endpoint.RecordTypeAAAA: { candidates: []*endpoint.Endpoint{suite.fooAAAA5}, }, }, }, { name: "conflict: cname and a records", args: args{ key: planKey{dnsName: "foo"}, row: &planTableRow{ current: []*endpoint.Endpoint{suite.fooV1Cname}, candidates: []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5}, records: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: { current: suite.fooV1Cname, candidates: []*endpoint.Endpoint{suite.fooV1Cname}, }, endpoint.RecordTypeA: { candidates: []*endpoint.Endpoint{suite.fooA5}, }, }, }, }, want: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: { current: suite.fooV1Cname, candidates: []*endpoint.Endpoint{}, }, endpoint.RecordTypeA: { candidates: []*endpoint.Endpoint{suite.fooA5}, }, }, }, { name: "conflict: cname, a, and aaaa records", args: args{ key: planKey{dnsName: "foo"}, row: &planTableRow{ current: []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA5}, candidates: []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA5}, records: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: { candidates: []*endpoint.Endpoint{suite.fooV1Cname}, }, endpoint.RecordTypeA: { current: suite.fooA5, candidates: []*endpoint.Endpoint{suite.fooA5}, }, endpoint.RecordTypeAAAA: { current: suite.fooAAAA5, candidates: []*endpoint.Endpoint{suite.fooAAAA5}, }, }, }, }, want: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: { candidates: []*endpoint.Endpoint{}, }, endpoint.RecordTypeA: { current: suite.fooA5, candidates: []*endpoint.Endpoint{suite.fooA5}, }, endpoint.RecordTypeAAAA: { current: suite.fooAAAA5, candidates: []*endpoint.Endpoint{suite.fooAAAA5}, }, }, }, } for _, tt := range tests { suite.Run(tt.name, func() { if got := suite.perResource.ResolveRecordTypes(tt.args.key, tt.args.row); !reflect.DeepEqual(got, tt.want) { suite.T().Errorf("PerResource.ResolveRecordTypes() = %v, want %v", got, tt.want) } }) } } func TestPerResource_ResolveRecordTypes_LogsWarning(t *testing.T) { const warnMsg = "contains conflicting record type candidates; discarding CNAME record" cname := &endpoint.Endpoint{DNSName: "foo", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"v1"}} a := &endpoint.Endpoint{DNSName: "foo", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.5.5.5"}} aaaa := &endpoint.Endpoint{DNSName: "foo", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}} tests := []struct { name string row *planTableRow expectsWarn bool }{ { name: "no warning: cname only", row: &planTableRow{ candidates: []*endpoint.Endpoint{cname}, records: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: {candidates: []*endpoint.Endpoint{cname}}, }, }, }, { name: "no warning: a and aaaa only", row: &planTableRow{ candidates: []*endpoint.Endpoint{a, aaaa}, records: map[string]*domainEndpoints{ endpoint.RecordTypeA: {candidates: []*endpoint.Endpoint{a}}, endpoint.RecordTypeAAAA: {candidates: []*endpoint.Endpoint{aaaa}}, }, }, }, { name: "warning: cname conflicts with a record", row: &planTableRow{ candidates: []*endpoint.Endpoint{cname, a}, records: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: {candidates: []*endpoint.Endpoint{cname}}, endpoint.RecordTypeA: {candidates: []*endpoint.Endpoint{a}}, }, }, expectsWarn: true, }, { name: "warning: cname conflicts with a and aaaa records", row: &planTableRow{ candidates: []*endpoint.Endpoint{cname, a, aaaa}, records: map[string]*domainEndpoints{ endpoint.RecordTypeCNAME: {candidates: []*endpoint.Endpoint{cname}}, endpoint.RecordTypeA: {candidates: []*endpoint.Endpoint{a}}, endpoint.RecordTypeAAAA: {candidates: []*endpoint.Endpoint{aaaa}}, }, }, expectsWarn: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) PerResource{}.ResolveRecordTypes(planKey{dnsName: "foo"}, tt.row) if tt.expectsWarn { logtest.TestHelperLogContainsWithLogLevel(warnMsg, log.WarnLevel, hook, t) } else { logtest.TestHelperLogNotContains(warnMsg, hook, t) } }) } } func TestConflictResolver(t *testing.T) { suite.Run(t, new(ResolverSuite)) } ================================================ FILE: plan/metrics.go ================================================ /* Copyright 2026 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 plan import ( "github.com/prometheus/client_golang/prometheus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/metrics" ) var ( // registryOwnerMismatchPerSync tracks records skipped due to owner mismatch. // The "domain" label uses the naked/apex domain (e.g., "example.com") rather than // full FQDNs to prevent cardinality explosion. With thousands of subdomains under // one apex domain, using full FQDNs would create excessive metric series. registryOwnerMismatchPerSync = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ Subsystem: "registry", Name: "skipped_records_owner_mismatch_per_sync", Help: "Number of records skipped with owner mismatch for each record type, owner mismatch ID and domain (vector).", }, []string{"record_type", "owner", "foreign_owner", "domain"}, ) ) func init() { metrics.RegisterMetric.MustRegister(registryOwnerMismatchPerSync) } // recordOwnerMismatch increments the per-sync gauge for a single skipped record due to an // owner mismatch. Labels capture the record type, expected owner, foreign owner, and the // record's parent domain (apex). Using the parent domain instead of the full FQDN prevents // metric cardinality explosion. func recordOwnerMismatch(owner string, current *endpoint.Endpoint) { registryOwnerMismatchPerSync.AddWithLabels( 1.0, current.RecordType, owner, current.GetOwner(), current.GetNakedDomain(), ) } ================================================ FILE: plan/metrics_test.go ================================================ /* Copyright 2026 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 plan import ( "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/endpoint" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) func TestOwnerMismatchMetric(t *testing.T) { currentA := &endpoint.Endpoint{ DNSName: "example.domain.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "other-owner", }, } desiredCname := &endpoint.Endpoint{ DNSName: "example.domain.com", Targets: endpoint.Targets{"target.example.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "my-owner", }, } p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: []*endpoint.Endpoint{currentA}, Desired: []*endpoint.Endpoint{desiredCname}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: "my-owner", } changes := p.Calculate().Changes assert.Empty(t, changes.Create, "expected no creates due to owner mismatch") testutils.TestHelperVerifyMetricsGaugeVectorWithLabels( t, 1.0, registryOwnerMismatchPerSync.Gauge, map[string]string{ "record_type": endpoint.RecordTypeA, "foreign_owner": "other-owner", "domain": "domain.com", }, ) } // TestCalculateOwnerMismatchDetection verifies that owner mismatch is detected // when desired endpoints want to create new record types on DNS names // that have current records owned by a different owner. func TestCalculateOwnerMismatchDetection(t *testing.T) { current := testutils.GenerateTestEndpointsWithDistribution( map[string]int{endpoint.RecordTypeA: 10}, map[string]int{"example.com": 1}, map[string]int{"other-owner": 1}, ) // Create desired endpoints: same DNS names but with different type records (new type triggers Create) var desired []*endpoint.Endpoint for _, ep := range current { desired = append(desired, &endpoint.Endpoint{ DNSName: ep.DNSName, Targets: endpoint.Targets{"abrakadabra"}, RecordType: endpoint.RecordTypeTXT, RecordTTL: 300, }) } p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: endpoint.KnownRecordTypes, OwnerID: "my-owner", } hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) changes := p.Calculate().Changes assert.Empty(t, changes.Create, "expected no creates due to owner mismatch") logtest.TestHelperLogContains("owner id does not match for one or more items to create", hook, t) } func TestOwnerMismatchMetricDistribution(t *testing.T) { p := newOwnerMismatchFixture() p.Calculate() testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 44, registryOwnerMismatchPerSync.Gauge, map[string]string{"record_type": endpoint.RecordTypeSRV}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 41, registryOwnerMismatchPerSync.Gauge, map[string]string{"foreign_owner": "owner1"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 125, registryOwnerMismatchPerSync.Gauge, map[string]string{"owner": "my-owner"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 21, registryOwnerMismatchPerSync.Gauge, map[string]string{"foreign_owner": "owner1", "domain": "open.net"}) testutils.TestHelperVerifyMetricsGaugeVectorWithLabels(t, 2, registryOwnerMismatchPerSync.Gauge, map[string]string{"record_type": endpoint.RecordTypeCNAME, "foreign_owner": "owner1", "domain": "open.net"}) } func BenchmarkOwnerMismatchMetricDistribution(b *testing.B) { p := newOwnerMismatchFixture(1000) for b.Loop() { p.Calculate() } } func newOwnerMismatchFixture(scale ...int) *Plan { factor := 1 if len(scale) > 0 && scale[0] > 1 { factor = scale[0] } current := testutils.GenerateTestEndpointsWithDistribution( map[string]int{ endpoint.RecordTypeA: 12 * factor, endpoint.RecordTypeAAAA: 27 * factor, endpoint.RecordTypeCNAME: 42 * factor, endpoint.RecordTypeSRV: 44 * factor, }, map[string]int{ "example.com": 1, "tld.org": 2, "open.net": 3, }, map[string]int{"owner1": 1, "owner2": 1, "owner3": 1}, ) var desired []*endpoint.Endpoint for _, ep := range current { desired = append(desired, &endpoint.Endpoint{ DNSName: ep.DNSName, Targets: endpoint.Targets{"txt-value"}, RecordType: endpoint.RecordTypeTXT, RecordTTL: 300, }) } return &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: endpoint.KnownRecordTypes, OwnerID: "my-owner", } } func TestFlushOwnerMismatch(t *testing.T) { tests := []struct { name string owner string current *endpoint.Endpoint calls int expected float64 expectedTags map[string]string }{ { name: "handles_missing_foreign_owner_label", owner: "me", current: &endpoint.Endpoint{ DNSName: "sub.domain.net", RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{}, }, calls: 1, expected: 1.0, expectedTags: map[string]string{ "record_type": endpoint.RecordTypeTXT, "owner": "me", "foreign_owner": "", "domain": "domain.net", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { registryOwnerMismatchPerSync.Gauge.Reset() for range tt.calls { recordOwnerMismatch(tt.owner, tt.current) } testutils.TestHelperVerifyMetricsGaugeVectorWithLabels( t, tt.expected, registryOwnerMismatchPerSync.Gauge, tt.expectedTags, ) }) } } ================================================ FILE: plan/plan.go ================================================ /* Copyright 2017 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 plan import ( "slices" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/idna" ) // Plan can convert a list of desired and current records to a series of create, // update and delete actions. type Plan struct { // List of current records // Records that already exist in the DNS provider (e.g., Route53, Cloudflare, etc.). These are fetched from the provider's registry. Current []*endpoint.Endpoint // List of desired records // Records that should exist based on Kubernetes resources (Ingress, Service, etc.). These are computed from the source. Desired []*endpoint.Endpoint // Policies under which the desired changes are calculated Policies []Policy // List of changes necessary to move towards desired state // Populated after calling Calculate() Changes *Changes // DomainFilter matches DNS names DomainFilter endpoint.MatchAllDomainFilters // ManagedRecords are DNS record types that will be considered for management. ManagedRecords []string // ExcludeRecords are DNS record types that will be excluded from management. ExcludeRecords []string // OwnerID of records to manage OwnerID string // Old owner ID we migrate from OldOwnerID string } // Changes holds lists of actions to be executed by dns providers type Changes struct { // Records that need to be created Create []*endpoint.Endpoint `json:"create,omitempty"` // Records that need to be updated (current data) UpdateOld []*endpoint.Endpoint `json:"updateOld,omitempty"` // Records that need to be updated (desired data) UpdateNew []*endpoint.Endpoint `json:"updateNew,omitempty"` // Records that need to be deleted Delete []*endpoint.Endpoint `json:"delete,omitempty"` } // planKey is a key for a row in `planTable`. type planKey struct { dnsName string setIdentifier string } // planTable is a supplementary struct for Plan // each row correspond to a planKey -> (current records + all desired records) // // planTable (-> = target) // -------------------------------------------------------------- // DNSName | Current record | Desired Records | // -------------------------------------------------------------- // foo.com | [->1.1.1.1 ] | [->1.1.1.1] | = no action // -------------------------------------------------------------- // bar.com | | [->191.1.1.1, ->190.1.1.1] | = create (bar.com [-> 190.1.1.1]) // -------------------------------------------------------------- // dog.com | [->1.1.1.2] | | = delete (dog.com [-> 1.1.1.2]) // -------------------------------------------------------------- // cat.com | [->::1, ->1.1.1.3] | [->1.1.1.3] | = update old (cat.com [-> ::1, -> 1.1.1.3]) new (cat.com [-> 1.1.1.3]) // -------------------------------------------------------------- // big.com | [->1.1.1.4] | [->ing.elb.com] | = update old (big.com [-> 1.1.1.4]) new (big.com [-> ing.elb.com]) // -------------------------------------------------------------- // "=", i.e. result of calculation relies on supplied ConflictResolver type planTable struct { rows map[planKey]*planTableRow resolver ConflictResolver } func newPlanTable() planTable { // TODO: make resolver configurable return planTable{map[planKey]*planTableRow{}, PerResource{}} } // planTableRow represents a set of current and desired domain resource records. type planTableRow struct { // current corresponds to the records currently occupying dns name on the dns provider. More than one record may // be represented here: for example A and AAAA. If the current domain record is a CNAME, no other record types // are allowed per [RFC 1034 3.6.2] // // [RFC 1034 3.6.2]: https://datatracker.ietf.org/doc/html/rfc1034#autoid-15 current []*endpoint.Endpoint // candidates corresponds to the list of records which would like to have this dnsName. candidates []*endpoint.Endpoint // records is a grouping of current and candidates by record type, for example A, AAAA, CNAME. records map[string]*domainEndpoints } // domainEndpoints is a grouping of current, which are existing records from the registry, and candidates, // which are desired records from the source. All records in this grouping have the same record type. type domainEndpoints struct { // current corresponds to existing record from the registry. Maybe nil if no current record of the type exists. current *endpoint.Endpoint // candidates corresponds to the list of records which would like to have this dnsName. candidates []*endpoint.Endpoint } func (t *planTable) addCurrent(e *endpoint.Endpoint) { key := t.newPlanKey(e) t.rows[key].current = append(t.rows[key].current, e) t.rows[key].records[e.RecordType].current = e } func (t *planTable) addCandidate(e *endpoint.Endpoint) { key := t.newPlanKey(e) row := t.rows[key] row.candidates = append(row.candidates, e) row.records[e.RecordType].candidates = append(row.records[e.RecordType].candidates, e) } func (t *planTable) newPlanKey(e *endpoint.Endpoint) planKey { key := planKey{ dnsName: idna.NormalizeDNSName(e.DNSName), setIdentifier: e.SetIdentifier, } if _, ok := t.rows[key]; !ok { t.rows[key] = &planTableRow{ records: make(map[string]*domainEndpoints), } } if _, ok := t.rows[key].records[e.RecordType]; !ok { t.rows[key].records[e.RecordType] = &domainEndpoints{} } return key } func (c *Changes) HasChanges() bool { if len(c.Create) > 0 || len(c.Delete) > 0 { return true } return !cmp.Equal(c.UpdateNew, c.UpdateOld, cmpopts.IgnoreUnexported(endpoint.Endpoint{})) } // Calculate computes the actions needed to move current state towards desired // state. It then passes those changes to the current policy for further // processing. It returns a copy of Plan with the changes populated. func (p *Plan) Calculate() *Plan { t := newPlanTable() if p.DomainFilter == nil { p.DomainFilter = endpoint.MatchAllDomainFilters(nil) } for _, current := range filterRecordsForPlan(p.Current, p.DomainFilter, p.ManagedRecords, p.ExcludeRecords) { t.addCurrent(current) } for _, desired := range filterRecordsForPlan(p.Desired, p.DomainFilter, p.ManagedRecords, p.ExcludeRecords) { t.addCandidate(desired) } if p.OwnerID != "" { registryOwnerMismatchPerSync.Gauge.Reset() } changes := p.calculateChanges(t) // Return a minimal plan with only the fields relevant to callers. // ManagedRecords is reset to the canonical defaults (A/AAAA/CNAME) — // this is intentional: it restores the default managed set regardless // of what was passed in, preventing callers that chain off Calculate() // from accidentally inheriting a non-default managed record configuration. // See: https://github.com/kubernetes-sigs/external-dns/pull/1915 plan := &Plan{ Current: p.Current, Desired: p.Desired, Changes: changes, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, } return plan } func (p *Plan) calculateChanges(t planTable) *Changes { changes := &Changes{} for key, row := range t.rows { switch { // dns name not taken case len(row.current) == 0: recordsByType := t.resolver.ResolveRecordTypes(key, row) for _, records := range recordsByType { if len(records.candidates) > 0 { changes.Create = append(changes.Create, t.resolver.ResolveCreate(records.candidates)) } } // dns name released or possibly owned by a different external dns case len(row.candidates) == 0: changes.Delete = append(changes.Delete, row.current...) // dns name is taken case len(row.candidates) > 0: p.appendTakenDNSNameChanges(t, changes, key, row) } } for _, pol := range p.Policies { changes = pol.Apply(changes) } // filter out updates this external dns does not have ownership claim over if p.OwnerID != "" { changes.Delete = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.Delete) changes.Delete = endpoint.RemoveDuplicates(changes.Delete) changes.UpdateOld = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.UpdateOld) changes.UpdateNew = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.UpdateNew) } return changes } func (p *Plan) appendTakenDNSNameChanges( t planTable, changes *Changes, key planKey, row *planTableRow) { // apply changes for each record type rowChanges := p.calculatePlanTableRowChanges(t, key, row) changes.Delete = append(changes.Delete, rowChanges.Delete...) changes.UpdateNew = append(changes.UpdateNew, rowChanges.UpdateNew...) changes.UpdateOld = append(changes.UpdateOld, rowChanges.UpdateOld...) if len(rowChanges.Create) == 0 { return } // only add creates if the external dns has ownership claim on the domain ownersMatch := true if p.OwnerID != "" { for _, current := range row.current { if !current.IsOwnedBy(p.OwnerID) { ownersMatch = false recordOwnerMismatch(p.OwnerID, current) if log.IsLevelEnabled(log.DebugLevel) { log.Debugf(`Skipping endpoint %v because owner id does not match for one or more items to create, found: "%s", required: "%s"`, current, current.Labels[endpoint.OwnerLabelKey], p.OwnerID) } } } } if ownersMatch { changes.Create = append(changes.Create, rowChanges.Create...) } } func (p *Plan) calculatePlanTableRowChanges(t planTable, key planKey, row *planTableRow) *Changes { changes := &Changes{} recordsByType := t.resolver.ResolveRecordTypes(key, row) for _, records := range recordsByType { switch { // record type not desired case records.current != nil && len(records.candidates) == 0: changes.Delete = append(changes.Delete, records.current) // new record type desired case records.current == nil && len(records.candidates) > 0: update := t.resolver.ResolveCreate(records.candidates) // creates are evaluated after all domain records have been processed to // validate that this external dns has ownership claim on the domain before // adding the records to planned changes. changes.Create = append(changes.Create, update) // update existing record case records.current != nil && len(records.candidates) > 0: p.appendEndpointUpdates(t, changes, records.current, records.candidates) } } return changes } func (p *Plan) appendEndpointUpdates(t planTable, changes *Changes, current *endpoint.Endpoint, candidates []*endpoint.Endpoint) { update := t.resolver.ResolveUpdate(current, candidates) if shouldUpdateTTL(update, current) || targetChanged(update, current) || p.providerSpecificChanged(update, current) || p.isOldOwnerIDSetAndDifferent(current) { inheritOwner(current, update) changes.UpdateNew = append(changes.UpdateNew, update) changes.UpdateOld = append(changes.UpdateOld, current) } } func (p *Plan) isOldOwnerIDSetAndDifferent(current *endpoint.Endpoint) bool { return p.OldOwnerID != "" && current.Labels[endpoint.OwnerLabelKey] != p.OldOwnerID } func inheritOwner(from, to *endpoint.Endpoint) { if to.Labels == nil { to.Labels = map[string]string{} } if from.Labels == nil { from.Labels = map[string]string{} } to.Labels[endpoint.OwnerLabelKey] = from.Labels[endpoint.OwnerLabelKey] } func targetChanged(desired, current *endpoint.Endpoint) bool { return !desired.Targets.Same(current.Targets) } func shouldUpdateTTL(desired, current *endpoint.Endpoint) bool { if !desired.RecordTTL.IsConfigured() { return false } return desired.RecordTTL != current.RecordTTL } func (p *Plan) providerSpecificChanged(desired, current *endpoint.Endpoint) bool { desiredProperties := make(map[string]endpoint.ProviderSpecificProperty, len(desired.ProviderSpecific)) for _, d := range desired.ProviderSpecific { desiredProperties[d.Name] = d } for _, c := range current.ProviderSpecific { if d, ok := desiredProperties[c.Name]; ok { if c.Value != d.Value { return true } delete(desiredProperties, c.Name) } else { return true } } return len(desiredProperties) > 0 } // filterRecordsForPlan removes records that are not relevant to the planner. // Currently, this just removes TXT records to prevent them from being // deleted erroneously by the planner (only the TXT registry should do this.) // // Per RFC 1034, CNAME records conflict with all other records - it is the // only record with this property. The behavior of the planner may need to be // made more sophisticated to codify this. func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.MatchAllDomainFilters, managedRecords, excludeRecords []string) []*endpoint.Endpoint { filtered := make([]*endpoint.Endpoint, 0, len(records)) for _, record := range records { // Ignore records that do not match the domain filter provided if !domainFilter.Match(record.DNSName) { log.Debugf("ignoring record %s that does not match domain filter", record.DNSName) continue } if IsManagedRecord(record.RecordType, managedRecords, excludeRecords) { filtered = append(filtered, record) } } return filtered } func IsManagedRecord(record string, managedRecords, excludeRecords []string) bool { if slices.Contains(excludeRecords, record) { return false } return slices.Contains(managedRecords, record) } ================================================ FILE: plan/plan_test.go ================================================ /* Copyright 2017 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 plan import ( "bytes" "encoding/json" "strings" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) type PlanTestSuite struct { suite.Suite fooV1Cname *endpoint.Endpoint fooV2Cname *endpoint.Endpoint fooV2CnameUppercase *endpoint.Endpoint fooV2TXT *endpoint.Endpoint fooV2CnameNoLabel *endpoint.Endpoint fooV3CnameSameResource *endpoint.Endpoint fooA5 *endpoint.Endpoint fooAAAA *endpoint.Endpoint dsA *endpoint.Endpoint dsAAAA *endpoint.Endpoint bar127A *endpoint.Endpoint bar127AWithTTL *endpoint.Endpoint bar127AWithProviderSpecificTrue *endpoint.Endpoint bar127AWithProviderSpecificFalse *endpoint.Endpoint bar127AWithProviderSpecificUnset *endpoint.Endpoint bar192A *endpoint.Endpoint multiple1 *endpoint.Endpoint multiple2 *endpoint.Endpoint multiple3 *endpoint.Endpoint domainFilterFiltered1 *endpoint.Endpoint domainFilterFiltered2 *endpoint.Endpoint domainFilterFiltered3 *endpoint.Endpoint domainFilterExcluded *endpoint.Endpoint } func (suite *PlanTestSuite) SetupTest() { suite.fooV1Cname = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"v1"}, RecordType: "CNAME", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-v1", endpoint.OwnerLabelKey: "pwner", }, } // same resource as fooV1Cname, but target is different. It will never be picked because its target lexicographically bigger than "v1" suite.fooV3CnameSameResource = &endpoint.Endpoint{ // TODO: remove this once endpoint can support multiple targets DNSName: "foo", Targets: endpoint.Targets{"v3"}, RecordType: "CNAME", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-v1", endpoint.OwnerLabelKey: "pwner", }, } suite.fooV2Cname = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"v2"}, RecordType: "CNAME", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-v2", }, } suite.fooV2CnameUppercase = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"V2"}, RecordType: "CNAME", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-v2", }, } suite.fooV2TXT = &endpoint.Endpoint{ DNSName: "foo", RecordType: "TXT", } suite.fooV2CnameNoLabel = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"v2"}, RecordType: "CNAME", } suite.fooA5 = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-5", }, } suite.fooAAAA = &endpoint.Endpoint{ DNSName: "foo", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: "AAAA", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-AAAA", }, } suite.dsA = &endpoint.Endpoint{ DNSName: "ds", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/ds", }, } suite.dsAAAA = &endpoint.Endpoint{ DNSName: "ds", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: "AAAA", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/ds-AAAAA", }, } suite.bar127A = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-127", }, } suite.bar127AWithTTL = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: "A", RecordTTL: 300, Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-127", }, } suite.bar127AWithProviderSpecificTrue = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-127", }, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "alias", Value: "false", }, endpoint.ProviderSpecificProperty{ Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true", }, }, } suite.bar127AWithProviderSpecificFalse = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-127", }, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, endpoint.ProviderSpecificProperty{ Name: "alias", Value: "false", }, }, } suite.bar127AWithProviderSpecificUnset = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-127", }, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "alias", Value: "false", }, }, } suite.bar192A = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/bar-192", }, } suite.multiple1 = &endpoint.Endpoint{ DNSName: "multiple", Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", SetIdentifier: "test-set-1", } suite.multiple2 = &endpoint.Endpoint{ DNSName: "multiple", Targets: endpoint.Targets{"192.168.0.2"}, RecordType: "A", SetIdentifier: "test-set-1", } suite.multiple3 = &endpoint.Endpoint{ DNSName: "multiple", Targets: endpoint.Targets{"192.168.0.2"}, RecordType: "A", SetIdentifier: "test-set-2", } suite.domainFilterFiltered1 = &endpoint.Endpoint{ DNSName: "foo.domain.tld", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A", } suite.domainFilterFiltered2 = &endpoint.Endpoint{ DNSName: "bar.domain.tld", Targets: endpoint.Targets{"1.2.3.5"}, RecordType: "A", } suite.domainFilterFiltered3 = &endpoint.Endpoint{ DNSName: "baz.domain.tld", Targets: endpoint.Targets{"1.2.3.6"}, RecordType: "A", } suite.domainFilterExcluded = &endpoint.Endpoint{ DNSName: "foo.ex.domain.tld", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: "A", } } func TestPlan_ChangesJson_DecodeEncode(t *testing.T) { ch := &Changes{ Create: []*endpoint.Endpoint{ { DNSName: "foo", }, }, UpdateOld: []*endpoint.Endpoint{ { DNSName: "bar", }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "baz", }, }, Delete: []*endpoint.Endpoint{ { DNSName: "qux", }, }, } jsonBytes, err := json.Marshal(ch) require.NoError(t, err) assert.JSONEq(t, `{"create":[{"dnsName":"foo"}],"updateOld":[{"dnsName":"bar"}],"updateNew":[{"dnsName":"baz"}],"delete":[{"dnsName":"qux"}]}`, string(jsonBytes)) var changes Changes err = json.NewDecoder(bytes.NewBuffer(jsonBytes)).Decode(&changes) require.NoError(t, err) assert.Equal(t, ch, &changes) } func TestPlan_ChangesJson_DecodeMixedCase(t *testing.T) { input := `{"Create":[{"dnsName":"foo"}],"UpdateOld":[{"dnsName":"bar"}],"updateNew":[{"dnsName":"baz"}],"Delete":[{"dnsName":"qux"}]}` var changes Changes err := json.NewDecoder(strings.NewReader(input)).Decode(&changes) require.NoError(t, err) assert.Len(t, changes.Create, 1) } func (suite *PlanTestSuite) TestSyncFirstRound() { current := []*endpoint.Endpoint{} desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname, suite.bar127A} expectedCreate := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar127A} // v1 is chosen because of resolver taking "min" expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestSyncSecondRound() { current := []*endpoint.Endpoint{suite.fooV1Cname} desired := []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname, suite.bar127A} expectedCreate := []*endpoint.Endpoint{suite.bar127A} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestSyncSecondRoundMigration() { current := []*endpoint.Endpoint{suite.fooV2CnameNoLabel} desired := []*endpoint.Endpoint{suite.fooV2Cname, suite.fooV1Cname, suite.bar127A} expectedCreate := []*endpoint.Endpoint{suite.bar127A} expectedUpdateOld := []*endpoint.Endpoint{suite.fooV2CnameNoLabel} expectedUpdateNew := []*endpoint.Endpoint{suite.fooV1Cname} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestSyncSecondRoundWithTTLChange() { current := []*endpoint.Endpoint{suite.bar127A} desired := []*endpoint.Endpoint{suite.bar127AWithTTL} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{suite.bar127A} expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithTTL} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificChange() { current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificNoChange() { current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes suite.False(changes.HasChanges()) } func (suite *PlanTestSuite) TestHasChangesCreate() { changes := &Changes{ Create: []*endpoint.Endpoint{suite.fooV1Cname}, } suite.True(changes.HasChanges()) } func (suite *PlanTestSuite) TestHasChangesDelete() { changes := &Changes{ Delete: []*endpoint.Endpoint{suite.fooV1Cname}, } suite.True(changes.HasChanges()) } func (suite *PlanTestSuite) TestHasChanges() { current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes suite.True(changes.HasChanges()) } func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificRemoval() { current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificAddition() { current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset} desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificUnset} expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestSyncSecondRoundWithOwnerInherited() { current := []*endpoint.Endpoint{suite.fooV1Cname} desired := []*endpoint.Endpoint{suite.fooV2Cname} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{suite.fooV1Cname} expectedUpdateNew := []*endpoint.Endpoint{{ DNSName: suite.fooV2Cname.DNSName, Targets: suite.fooV2Cname.Targets, RecordType: suite.fooV2Cname.RecordType, RecordTTL: suite.fooV2Cname.RecordTTL, Labels: map[string]string{ endpoint.ResourceLabelKey: suite.fooV2Cname.Labels[endpoint.ResourceLabelKey], endpoint.OwnerLabelKey: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], }, }} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestIdempotency() { current := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname} desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV2Cname} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestRecordTypeChange() { current := []*endpoint.Endpoint{suite.fooV1Cname} desired := []*endpoint.Endpoint{suite.fooA5} expectedCreate := []*endpoint.Endpoint{suite.fooA5} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestExistingCNameWithDualStackDesired() { current := []*endpoint.Endpoint{suite.fooV1Cname} desired := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} expectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestExistingDualStackWithCNameDesired() { suite.fooA5.Labels[endpoint.OwnerLabelKey] = "nerf" suite.fooAAAA.Labels[endpoint.OwnerLabelKey] = "nerf" current := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} desired := []*endpoint.Endpoint{suite.fooV2Cname} expectedCreate := []*endpoint.Endpoint{suite.fooV2Cname} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: suite.fooA5.Labels[endpoint.OwnerLabelKey], } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } // TestExistingOwnerNotMatchingDualStackDesired validates that if there is an existing // record for a domain but there is no ownership claim over it and there are desired // records no changes are planed. Only domains that have explicit ownership claims should // be updated. func (suite *PlanTestSuite) TestExistingOwnerNotMatchingDualStackDesired() { suite.fooA5.Labels = nil current := []*endpoint.Endpoint{suite.fooA5} desired := []*endpoint.Endpoint{suite.fooV2Cname} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: "pwner", } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } // TestConflictingCurrentNonConflictingDesired is a bit of a corner case as it would indicate // that the provider is not following valid DNS rules or there may be some // caching issues. In this case since the desired records are not conflicting // the updates will end up with the conflict resolved. func (suite *PlanTestSuite) TestConflictingCurrentNonConflictingDesired() { suite.fooA5.Labels[endpoint.OwnerLabelKey] = suite.fooV1Cname.Labels[endpoint.OwnerLabelKey] current := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5} desired := []*endpoint.Endpoint{suite.fooA5} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } // TestConflictingCurrentNoDesired is a bit of a corner case as it would indicate // that the provider is not following valid DNS rules or there may be some // caching issues. In this case there are no desired enpoint candidates so plan // on deleting the records. func (suite *PlanTestSuite) TestConflictingCurrentNoDesired() { suite.fooA5.Labels[endpoint.OwnerLabelKey] = suite.fooV1Cname.Labels[endpoint.OwnerLabelKey] current := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5} desired := []*endpoint.Endpoint{} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } // TestCurrentWithConflictingDesired simulates where the desired records result in conflicting records types. // This could be the result of multiple sources generating conflicting records types. In this case the conflict // resolver should prefer the A and AAAA record candidate and delete the other records. func (suite *PlanTestSuite) TestCurrentWithConflictingDesired() { suite.fooV1Cname.Labels[endpoint.OwnerLabelKey] = "nerf" current := []*endpoint.Endpoint{suite.fooV1Cname} desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA} expectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.fooV1Cname} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: suite.fooV1Cname.Labels[endpoint.OwnerLabelKey], } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } // TestNoCurrentWithConflictingDesired simulates where the desired records result in conflicting records types. // This could be the result of multiple sources generating conflicting records types. In this case, the // conflict resolver should prefer the A and AAAA record and drop the other candidate record types. func (suite *PlanTestSuite) TestNoCurrentWithConflictingDesired() { current := []*endpoint.Endpoint{} desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooA5, suite.fooAAAA} expectedCreate := []*endpoint.Endpoint{suite.fooA5, suite.fooAAAA} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestIgnoreTXT() { current := []*endpoint.Endpoint{suite.fooV2TXT} desired := []*endpoint.Endpoint{suite.fooV2Cname} expectedCreate := []*endpoint.Endpoint{suite.fooV2Cname} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestExcludeTXT() { current := []*endpoint.Endpoint{suite.fooV2TXT} desired := []*endpoint.Endpoint{suite.fooV2Cname} expectedCreate := []*endpoint.Endpoint{suite.fooV2Cname} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeTXT}, ExcludeRecords: []string{endpoint.RecordTypeTXT}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestIgnoreTargetCase() { current := []*endpoint.Endpoint{suite.fooV2Cname} desired := []*endpoint.Endpoint{suite.fooV2CnameUppercase} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestRemoveEndpoint() { current := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar192A} desired := []*endpoint.Endpoint{suite.fooV1Cname} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.bar192A} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestRemoveEndpointWithUpsert() { current := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar192A} desired := []*endpoint.Endpoint{suite.fooV1Cname} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&UpsertOnlyPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestMultipleRecordsSameNameDifferentSetIdentifier() { current := []*endpoint.Endpoint{suite.multiple1} desired := []*endpoint.Endpoint{suite.multiple2, suite.multiple3} expectedCreate := []*endpoint.Endpoint{suite.multiple3} expectedUpdateOld := []*endpoint.Endpoint{suite.multiple1} expectedUpdateNew := []*endpoint.Endpoint{suite.multiple2} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestSetIdentifierUpdateCreatesAndDeletes() { current := []*endpoint.Endpoint{suite.multiple2} desired := []*endpoint.Endpoint{suite.multiple3} expectedCreate := []*endpoint.Endpoint{suite.multiple3} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.multiple2} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestDomainFiltersInitial() { current := []*endpoint.Endpoint{suite.domainFilterExcluded} desired := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3} expectedCreate := []*endpoint.Endpoint{suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} domainFilter := endpoint.NewDomainFilterWithExclusions([]string{"domain.tld"}, []string{"ex.domain.tld"}) p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestDomainFiltersUpdate() { current := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2} desired := []*endpoint.Endpoint{suite.domainFilterExcluded, suite.domainFilterFiltered1, suite.domainFilterFiltered2, suite.domainFilterFiltered3} expectedCreate := []*endpoint.Endpoint{suite.domainFilterFiltered3} expectedUpdateOld := []*endpoint.Endpoint{} expectedUpdateNew := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{} domainFilter := endpoint.NewDomainFilterWithExclusions([]string{"domain.tld"}, []string{"ex.domain.tld"}) p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func (suite *PlanTestSuite) TestAAAARecords() { current := []*endpoint.Endpoint{} desired := []*endpoint.Endpoint{suite.fooAAAA} expectedCreate := []*endpoint.Endpoint{suite.fooAAAA} expectNoChanges := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.Delete, expectNoChanges) validateEntries(suite.T(), changes.UpdateOld, expectNoChanges) validateEntries(suite.T(), changes.UpdateNew, expectNoChanges) } func (suite *PlanTestSuite) TestDualStackRecords() { current := []*endpoint.Endpoint{} desired := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} expectedCreate := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} expectNoChanges := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.Delete, expectNoChanges) validateEntries(suite.T(), changes.UpdateOld, expectNoChanges) validateEntries(suite.T(), changes.UpdateNew, expectNoChanges) } func (suite *PlanTestSuite) TestDualStackRecordsDelete() { current := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} desired := []*endpoint.Endpoint{} expectedDelete := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} expectNoChanges := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Delete, expectedDelete) validateEntries(suite.T(), changes.Create, expectNoChanges) validateEntries(suite.T(), changes.UpdateOld, expectNoChanges) validateEntries(suite.T(), changes.UpdateNew, expectNoChanges) } func (suite *PlanTestSuite) TestDualStackToSingleStack() { current := []*endpoint.Endpoint{suite.dsA, suite.dsAAAA} desired := []*endpoint.Endpoint{suite.dsA} expectedDelete := []*endpoint.Endpoint{suite.dsAAAA} expectNoChanges := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Delete, expectedDelete) validateEntries(suite.T(), changes.Create, expectNoChanges) validateEntries(suite.T(), changes.UpdateOld, expectNoChanges) validateEntries(suite.T(), changes.UpdateNew, expectNoChanges) } func (suite *PlanTestSuite) TestRecordOwnerIdMigration() { suite.fooA5.Labels[endpoint.OwnerLabelKey] = "bar" current := []*endpoint.Endpoint{suite.fooA5} desired := []*endpoint.Endpoint{suite.fooA5} expectedCreate := []*endpoint.Endpoint{} expectedUpdateOld := []*endpoint.Endpoint{suite.fooA5} expectedUpdateNew := []*endpoint.Endpoint{suite.fooA5} expectedDelete := []*endpoint.Endpoint{} p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, OwnerID: suite.fooA5.Labels[endpoint.OwnerLabelKey], OldOwnerID: "foo", } changes := p.Calculate().Changes validateEntries(suite.T(), changes.Create, expectedCreate) validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) validateEntries(suite.T(), changes.Delete, expectedDelete) } func TestPlan(t *testing.T) { suite.Run(t, new(PlanTestSuite)) } // validateEntries validates that the list of entries matches expected. func validateEntries(t *testing.T, entries, expected []*endpoint.Endpoint) { if !testutils.SameEndpoints(entries, expected) { t.Fatalf("expected %q to match %q", entries, expected) } } func TestShouldUpdateProviderSpecific(tt *testing.T) { for _, test := range []struct { name string current *endpoint.Endpoint desired *endpoint.Endpoint shouldUpdate bool }{ { name: "skip AWS target health", current: &endpoint.Endpoint{ DNSName: "foo.com", ProviderSpecific: []endpoint.ProviderSpecificProperty{ {Name: "aws/evaluate-target-health", Value: "true"}, }, }, desired: &endpoint.Endpoint{ DNSName: "bar.com", ProviderSpecific: []endpoint.ProviderSpecificProperty{ {Name: "aws/evaluate-target-health", Value: "true"}, }, }, shouldUpdate: false, }, { name: "custom property unchanged", current: &endpoint.Endpoint{ ProviderSpecific: []endpoint.ProviderSpecificProperty{ {Name: "custom/property", Value: "true"}, }, }, desired: &endpoint.Endpoint{ ProviderSpecific: []endpoint.ProviderSpecificProperty{ {Name: "custom/property", Value: "true"}, }, }, shouldUpdate: false, }, { name: "custom property value changed", current: &endpoint.Endpoint{ ProviderSpecific: []endpoint.ProviderSpecificProperty{ {Name: "custom/property", Value: "true"}, }, }, desired: &endpoint.Endpoint{ ProviderSpecific: []endpoint.ProviderSpecificProperty{ {Name: "custom/property", Value: "false"}, }, }, shouldUpdate: true, }, { name: "custom property key changed", current: &endpoint.Endpoint{ ProviderSpecific: []endpoint.ProviderSpecificProperty{ {Name: "custom/property", Value: "true"}, }, }, desired: &endpoint.Endpoint{ ProviderSpecific: []endpoint.ProviderSpecificProperty{ {Name: "new/property", Value: "true"}, }, }, shouldUpdate: true, }, } { tt.Run(test.name, func(t *testing.T) { plan := &Plan{ Current: []*endpoint.Endpoint{test.current}, Desired: []*endpoint.Endpoint{test.desired}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } b := plan.providerSpecificChanged(test.desired, test.current) assert.Equal(t, test.shouldUpdate, b) }) } } func TestOwnerMismatchLogsDebug(t *testing.T) { const wantMsg = "owner id does not match" // current A record owned by someone else; desired CNAME owned by us. // The CNAME has no current record → triggers a create, which activates // the owner-check block and the debug log. current := &endpoint.Endpoint{ DNSName: "foo", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, Labels: map[string]string{endpoint.OwnerLabelKey: "other"}, } desired := &endpoint.Endpoint{ DNSName: "foo", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bar.example.com"}, Labels: map[string]string{endpoint.OwnerLabelKey: "pwner"}, } p := &Plan{ Policies: []Policy{&SyncPolicy{}}, Current: []*endpoint.Endpoint{current}, Desired: []*endpoint.Endpoint{desired}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, OwnerID: "pwner", } hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) p.Calculate() logtest.TestHelperLogContainsWithLogLevel(wantMsg, log.DebugLevel, hook, t) } ================================================ FILE: plan/policy.go ================================================ /* Copyright 2017 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 plan // Policy allows to apply different rules to a set of changes. type Policy interface { Apply(changes *Changes) *Changes } // Policies is a registry of available policies, keyed by name. var Policies = map[string]Policy{ "sync": &SyncPolicy{}, "upsert-only": &UpsertOnlyPolicy{}, "create-only": &CreateOnlyPolicy{}, } // SyncPolicy allows for full synchronization of DNS records. type SyncPolicy struct{} // Apply is a pass-through: sync allows all changes without restriction. func (p *SyncPolicy) Apply(changes *Changes) *Changes { return changes } // UpsertOnlyPolicy allows everything but deleting DNS records. type UpsertOnlyPolicy struct{} // Apply applies the upsert-only policy which strips out any deletions. func (p *UpsertOnlyPolicy) Apply(changes *Changes) *Changes { return &Changes{ Create: changes.Create, UpdateOld: changes.UpdateOld, UpdateNew: changes.UpdateNew, } } // CreateOnlyPolicy allows only creating DNS records. type CreateOnlyPolicy struct{} // Apply applies the create-only policy which strips out updates and deletions. func (p *CreateOnlyPolicy) Apply(changes *Changes) *Changes { return &Changes{ Create: changes.Create, } } ================================================ FILE: plan/policy_test.go ================================================ /* Copyright 2017 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 plan import ( "reflect" "testing" "sigs.k8s.io/external-dns/endpoint" ) // TestApply tests that applying a policy results in the correct set of changes. func TestApply(t *testing.T) { // empty list of records empty := []*endpoint.Endpoint{} // a simple entry fooV1 := []*endpoint.Endpoint{{DNSName: "foo", Targets: endpoint.Targets{"v1"}}} // the same entry but with different target fooV2 := []*endpoint.Endpoint{{DNSName: "foo", Targets: endpoint.Targets{"v2"}}} // another two simple entries bar := []*endpoint.Endpoint{{DNSName: "bar", Targets: endpoint.Targets{"v1"}}} baz := []*endpoint.Endpoint{{DNSName: "baz", Targets: endpoint.Targets{"v1"}}} for _, tc := range []struct { policy Policy changes *Changes expected *Changes }{ { // SyncPolicy doesn't modify the set of changes. &SyncPolicy{}, &Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar}, &Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar}, }, { // UpsertOnlyPolicy clears the list of deletions. &UpsertOnlyPolicy{}, &Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar}, &Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: empty}, }, { // CreateOnlyPolicy clears the list of updates and deletions. &CreateOnlyPolicy{}, &Changes{Create: baz, UpdateOld: fooV1, UpdateNew: fooV2, Delete: bar}, &Changes{Create: baz, UpdateOld: empty, UpdateNew: empty, Delete: empty}, }, } { // apply policy changes := tc.policy.Apply(tc.changes) // validate changes after applying policy validateEntries(t, changes.Create, tc.expected.Create) validateEntries(t, changes.UpdateOld, tc.expected.UpdateOld) validateEntries(t, changes.UpdateNew, tc.expected.UpdateNew) validateEntries(t, changes.Delete, tc.expected.Delete) } } // TestPolicies tests that policies are correctly registered. func TestPolicies(t *testing.T) { validatePolicy(t, Policies["sync"], &SyncPolicy{}) validatePolicy(t, Policies["upsert-only"], &UpsertOnlyPolicy{}) validatePolicy(t, Policies["create-only"], &CreateOnlyPolicy{}) } // validatePolicy validates that a given policy is of the given type. func validatePolicy(t *testing.T, policy, expected Policy) { policyType := reflect.TypeOf(policy).String() expectedType := reflect.TypeOf(expected).String() if policyType != expectedType { t.Errorf("expected %q to match %q", policyType, expectedType) } } ================================================ FILE: provider/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - provider ================================================ FILE: provider/akamai/akamai.go ================================================ /* Copyright 2017 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 akamai import ( "context" "errors" "fmt" "os" "strconv" "strings" dns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2" "github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( // Default Record TTL defaultTTL = 600 maxUint = ^uint(0) maxInt = int(maxUint >> 1) ) // AkamaiDNSService is a proxy interface of the Akamai edgegrid configdns-v2 package that can be stubbed for testing. type AkamaiDNSService interface { ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error) DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error CreateRecordsets(recordsets *dns.Recordsets, zone string, recLock bool) error } type AkamaiConfig struct { DomainFilter *endpoint.DomainFilter ZoneIDFilter provider.ZoneIDFilter ServiceConsumerDomain string ClientToken string ClientSecret string AccessToken string EdgercPath string EdgercSection string MaxBody int AccountKey string DryRun bool } // AkamaiProvider implements the DNS provider for Akamai. type AkamaiProvider struct { provider.BaseProvider // Edgedns zones to filter on domainFilter *endpoint.DomainFilter // Contract Ids to filter on zoneIDFilter provider.ZoneIDFilter // Edgegrid library configuration config *edgegrid.Config dryRun bool // Defines client. Allows for mocking. client AkamaiDNSService } type akamaiZones struct { Zones []akamaiZone `json:"zones"` } type akamaiZone struct { ContractID string `json:"contractId"` Zone string `json:"zone"` } // New creates an Akamai provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider( AkamaiConfig{ DomainFilter: domainFilter, ZoneIDFilter: provider.NewZoneIDFilter(cfg.ZoneIDFilter), ServiceConsumerDomain: cfg.AkamaiServiceConsumerDomain, ClientToken: cfg.AkamaiClientToken, ClientSecret: cfg.AkamaiClientSecret, AccessToken: cfg.AkamaiAccessToken, EdgercPath: cfg.AkamaiEdgercPath, EdgercSection: cfg.AkamaiEdgercSection, DryRun: cfg.DryRun, }, nil) } // newAkamaiProvider initializes a new Akamai DNS based Provider. func newProvider(akamaiConfig AkamaiConfig, akaService AkamaiDNSService) (provider.Provider, error) { var edgeGridConfig edgegrid.Config // environment overrides edgerc file but config needs to be complete if akamaiConfig.ServiceConsumerDomain == "" || akamaiConfig.ClientToken == "" || akamaiConfig.ClientSecret == "" || akamaiConfig.AccessToken == "" { // Kubernetes config incomplete or non existent. Can't mix and match. // Look for Akamai environment or .edgerd creds var err error edgeGridConfig, err = edgegrid.Init(akamaiConfig.EdgercPath, akamaiConfig.EdgercSection) // use default .edgerc location and section if err != nil { log.Errorf("Edgegrid Init Failed") return &AkamaiProvider{}, err // return an empty provider for backward compatibility } edgeGridConfig.HeaderToSign = append(edgeGridConfig.HeaderToSign, "X-External-DNS") } else { // Use external-dns config edgeGridConfig = edgegrid.Config{ Host: akamaiConfig.ServiceConsumerDomain, ClientToken: akamaiConfig.ClientToken, ClientSecret: akamaiConfig.ClientSecret, AccessToken: akamaiConfig.AccessToken, MaxBody: 131072, // same default val as used by Edgegrid HeaderToSign: []string{ "X-External-DNS", }, Debug: false, } // Check for edgegrid overrides if envval, ok := os.LookupEnv("AKAMAI_MAX_BODY"); ok { if i, err := strconv.Atoi(envval); err == nil { edgeGridConfig.MaxBody = i log.Debugf("Edgegrid maxbody set to %s", envval) } } if envval, ok := os.LookupEnv("AKAMAI_ACCOUNT_KEY"); ok { edgeGridConfig.AccountKey = envval log.Debugf("Edgegrid applying account key %s", envval) } if envval, ok := os.LookupEnv("AKAMAI_DEBUG"); ok { if dbgval, err := strconv.ParseBool(envval); err == nil { edgeGridConfig.Debug = dbgval log.Debugf("Edgegrid debug set to %s", envval) } } } provider := &AkamaiProvider{ domainFilter: akamaiConfig.DomainFilter, zoneIDFilter: akamaiConfig.ZoneIDFilter, config: &edgeGridConfig, dryRun: akamaiConfig.DryRun, } if akaService != nil { log.Debugf("Using STUB") provider.client = akaService } else { provider.client = provider } // Init library for direct endpoint calls dns.Init(edgeGridConfig) return provider, nil } func (p AkamaiProvider) ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) { return dns.ListZones(queryArgs) } func (p AkamaiProvider) GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) { return dns.GetRecordsets(zone, queryArgs) } func (p AkamaiProvider) CreateRecordsets(recordsets *dns.Recordsets, zone string, reclock bool) error { return recordsets.Save(zone, reclock) } func (p AkamaiProvider) GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error) { return dns.GetRecord(zone, name, recordtype) } func (p AkamaiProvider) DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error { return record.Delete(zone, recLock) } func (p AkamaiProvider) UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error { return record.Update(zone, recLock) } // Fetch zones using Edgegrid DNS v2 API func (p AkamaiProvider) fetchZones() (akamaiZones, error) { filteredZones := akamaiZones{Zones: make([]akamaiZone, 0)} queryArgs := dns.ZoneListQueryArgs{Types: "primary", ShowAll: true} // filter based on contractIds if len(p.zoneIDFilter.ZoneIDs) > 0 { queryArgs.ContractIds = strings.Join(p.zoneIDFilter.ZoneIDs, ",") } resp, err := p.client.ListZones(queryArgs) // retrieve all primary zones filtered by contract ids if err != nil { log.Errorf("Failed to fetch zones from Akamai") return filteredZones, err } for _, zone := range resp.Zones { if p.domainFilter.Match(zone.Zone) { filteredZones.Zones = append(filteredZones.Zones, akamaiZone{ContractID: zone.ContractId, Zone: zone.Zone}) log.Debugf("Fetched zone: '%s' (ZoneID: %s)", zone.Zone, zone.ContractId) } } lenFilteredZones := len(filteredZones.Zones) if lenFilteredZones == 0 { log.Warnf("No zones could be fetched") } else { log.Debugf("Fetched '%d' zones from Akamai", lenFilteredZones) } return filteredZones, nil } // Records returns the list of records in a given zone. func (p AkamaiProvider) Records(context.Context) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint zones, err := p.fetchZones() // returns a filtered set of zones if err != nil { log.Warnf("Failed to identify target zones! Error: %s", err.Error()) return endpoints, err } for _, zone := range zones.Zones { recordsets, err := p.client.GetRecordsets(zone.Zone, dns.RecordsetQueryArgs{ShowAll: true}) if err != nil { log.Errorf("Recordsets retrieval for zone: '%s' failed! %s", zone.Zone, err.Error()) continue } if len(recordsets.Recordsets) == 0 { log.Warnf("Zone %s contains no recordsets", zone.Zone) } for _, recordset := range recordsets.Recordsets { if !provider.SupportedRecordType(recordset.Type) { log.Debugf("Skipping endpoint DNSName: '%s' RecordType: '%s'. Record type not supported.", recordset.Name, recordset.Type) continue } if !p.domainFilter.Match(recordset.Name) { log.Debugf("Skipping endpoint. Record name %s doesn't match containing zone %s.", recordset.Name, zone) continue } var temp any = int64(recordset.TTL) ttl := endpoint.TTL(temp.(int64)) endpoints = append(endpoints, endpoint.NewEndpointWithTTL(recordset.Name, recordset.Type, ttl, trimTxtRdata(recordset.Rdata, recordset.Type)...)) log.Debugf("Fetched endpoint DNSName: '%s' RecordType: '%s' Rdata: '%s')", recordset.Name, recordset.Type, recordset.Rdata) } } lenEndpoints := len(endpoints) if lenEndpoints == 0 { log.Warnf("No endpoints could be fetched") } else { log.Debugf("Fetched '%d' endpoints from Akamai", lenEndpoints) log.Debugf("Endpoints [%v]", endpoints) } return endpoints, nil } // ApplyChanges applies a given set of changes in a given zone. func (p AkamaiProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { zoneNameIDMapper := provider.ZoneIDName{} zones, err := p.fetchZones() if err != nil { log.Errorf("Failed to fetch zones from Akamai") return err } for _, z := range zones.Zones { zoneNameIDMapper[z.Zone] = z.Zone } log.Debugf("Processing zones: [%v]", zoneNameIDMapper) // Create recordsets log.Debugf("Create Changes requested [%v]", changes.Create) if err := p.createRecordsets(zoneNameIDMapper, changes.Create); err != nil { return err } // Delete recordsets log.Debugf("Delete Changes requested [%v]", changes.Delete) if err := p.deleteRecordsets(zoneNameIDMapper, changes.Delete); err != nil { return err } // Update recordsets log.Debugf("Update Changes requested [%v]", changes.UpdateNew) if err := p.updateNewRecordsets(zoneNameIDMapper, changes.UpdateNew); err != nil { return err } // Check that all old endpoints were accounted for revRecs := changes.Delete revRecs = append(revRecs, changes.UpdateNew...) for _, rec := range changes.UpdateOld { found := false for _, r := range revRecs { if rec.DNSName == r.DNSName { found = true break } } if !found { log.Warnf("UpdateOld endpoint '%s' is not accounted for in UpdateNew|Delete endpoint list", rec.DNSName) } } return nil } // Create DNS Recordset func newAkamaiRecordset(dnsName, recordType string, ttl int, targets []string) dns.Recordset { return dns.Recordset{ Name: strings.TrimSuffix(dnsName, "."), Rdata: targets, Type: recordType, TTL: ttl, } } // cleanTargets preps recordset rdata if necessary for EdgeDNS func cleanTargets(rtype string, targets ...string) []string { log.Debugf("Targets to clean: [%v]", targets) switch rtype { case "CNAME", "SRV": for idx, target := range targets { targets[idx] = strings.TrimSuffix(target, ".") } case "TXT": for idx, target := range targets { log.Debugf("TXT data to clean: [%s]", target) // need to embed text data in quotes. Make sure not piling on target = strings.Trim(target, "\"") // bug in DNS API with embedded quotes. if strings.Contains(target, "owner") && strings.Contains(target, "\"") { target = strings.ReplaceAll(target, "\"", "`") } targets[idx] = "\"" + target + "\"" } } log.Debugf("Clean targets: [%v]", targets) return targets } // trimTxtRdata removes surrounding quotes for received TXT rdata func trimTxtRdata(rdata []string, rtype string) []string { if rtype == "TXT" { for idx, d := range rdata { if strings.Contains(d, "`") { rdata[idx] = strings.ReplaceAll(d, "`", "\"") } } } log.Debugf("Trimmed data: [%v]", rdata) return rdata } func ttlAsInt(src endpoint.TTL) int { var temp any = int64(src) temp64 := temp.(int64) var ttl = defaultTTL if temp64 > 0 && temp64 <= int64(maxInt) { ttl = int(temp64) } return ttl } // Create Endpoint Recordsets func (p AkamaiProvider) createRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error { if len(endpoints) == 0 { log.Info("No endpoints to create") return nil } endpointsByZone := edgeChangesByZone(zoneNameIDMapper, endpoints) // create all recordsets by zone for zone, endpoints := range endpointsByZone { recordsets := &dns.Recordsets{Recordsets: make([]dns.Recordset, 0)} for _, endpoint := range endpoints { newrec := newAkamaiRecordset(endpoint.DNSName, endpoint.RecordType, ttlAsInt(endpoint.RecordTTL), cleanTargets(endpoint.RecordType, endpoint.Targets...)) logfields := log.Fields{ "record": newrec.Name, "type": newrec.Type, "ttl": newrec.TTL, "target": fmt.Sprintf("%v", newrec.Rdata), "zone": zone, } log.WithFields(logfields).Info("Creating recordsets") recordsets.Recordsets = append(recordsets.Recordsets, newrec) } if p.dryRun { continue } // Create recordsets all at once err := p.client.CreateRecordsets(recordsets, zone, true) if err != nil { log.Errorf("Failed to create endpoints for DNS zone %s. Error: %s", zone, err.Error()) return err } } return nil } func (p AkamaiProvider) deleteRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error { for _, endpoint := range endpoints { zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName) if zoneName == "" { log.Debugf("Skipping Akamai Edge DNS endpoint deletion: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType) continue } log.Infof("Akamai Edge DNS recordset deletion- Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets) if p.dryRun { continue } recName := strings.TrimSuffix(endpoint.DNSName, ".") rec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType) if err != nil { recordError := &dns.RecordError{} if errors.As(err, &recordError) { return fmt.Errorf("endpoint deletion. record validation failed. error: %w", err) } log.Infof("Endpoint deletion. Record doesn't exist. Name: %s, Type: %s", recName, endpoint.RecordType) continue } if err := p.client.DeleteRecord(rec, zoneName, true); err != nil { log.Errorf("edge dns recordset deletion failed. error: %s", err.Error()) return err } } return nil } // Update endpoint recordsets func (p AkamaiProvider) updateNewRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error { for _, endpoint := range endpoints { zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName) if zoneName == "" { log.Debugf("Skipping Akamai Edge DNS endpoint update: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType) continue } log.Infof("Akamai Edge DNS recordset update - Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets) if p.dryRun { continue } recName := strings.TrimSuffix(endpoint.DNSName, ".") rec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType) if err != nil { log.Errorf("Endpoint update. Record validation failed. Error: %s", err.Error()) return err } rec.TTL = ttlAsInt(endpoint.RecordTTL) rec.Target = cleanTargets(endpoint.RecordType, endpoint.Targets...) if err := p.client.UpdateRecord(rec, zoneName, true); err != nil { log.Errorf("Akamai Edge DNS recordset update failed. Error: %s", err.Error()) return err } } return nil } // edgeChangesByZone separates a multi-zone change into a single change per zone. func edgeChangesByZone(zoneMap provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint { createsByZone := make(map[string][]*endpoint.Endpoint, len(zoneMap)) for _, z := range zoneMap { createsByZone[z] = make([]*endpoint.Endpoint, 0) } for _, ep := range endpoints { zone, _ := zoneMap.FindZone(ep.DNSName) if zone != "" { createsByZone[zone] = append(createsByZone[zone], ep) continue } log.Debugf("Skipping Akamai Edge DNS creation of endpoint: '%s' type: '%s', it does not match against Domain filters", ep.DNSName, ep.RecordType) } return createsByZone } ================================================ FILE: provider/akamai/akamai_test.go ================================================ /* Copyright 2017 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 akamai import ( "encoding/json" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" dns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) type edgednsStubData struct { objType string // zone, record, recordsets output []any } type edgednsStub struct { stubData map[string]edgednsStubData } func newStub() *edgednsStub { return &edgednsStub{ stubData: make(map[string]edgednsStubData), } } func createAkamaiStubProvider(stub *edgednsStub, domfilter *endpoint.DomainFilter, idfilter provider.ZoneIDFilter) (*AkamaiProvider, error) { akamaiConfig := AkamaiConfig{ DomainFilter: domfilter, ZoneIDFilter: idfilter, ServiceConsumerDomain: "testzone.com", ClientToken: "test_token", ClientSecret: "test_client_secret", AccessToken: "test_access_token", } prov, err := newProvider(akamaiConfig, stub) aprov := prov.(*AkamaiProvider) return aprov, err } func (r *edgednsStub) createStubDataEntry(objtype string) { log.Debugf("Creating stub data entry") if _, exists := r.stubData[objtype]; !exists { r.stubData[objtype] = edgednsStubData{objType: objtype} } return } func (r *edgednsStub) setOutput(objtype string, output []any) { log.Debugf("Setting output to %v", output) r.createStubDataEntry(objtype) stubdata := r.stubData[objtype] stubdata.output = output r.stubData[objtype] = stubdata return } func (r *edgednsStub) ListZones(_ dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) { log.Debugf("Entering ListZones") // Ignore Metadata` resp := &dns.ZoneListResponse{} zones := make([]*dns.ZoneResponse, 0) for _, zname := range r.stubData["zone"].output { log.Debugf("Processing output: %v", zname) zn := &dns.ZoneResponse{Zone: zname.(string), ContractId: "contract"} log.Debugf("Created Zone Object: %v", zn) zones = append(zones, zn) } resp.Zones = zones return resp, nil } func (r *edgednsStub) GetRecordsets(_ string, _ dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) { log.Debugf("Entering GetRecordsets") // Ignore Metadata` resp := &dns.RecordSetResponse{} sets := make([]dns.Recordset, 0) for _, rec := range r.stubData["recordset"].output { rset := rec.(dns.Recordset) sets = append(sets, rset) } resp.Recordsets = sets return resp, nil } func (r *edgednsStub) CreateRecordsets(_ *dns.Recordsets, _ string, _ bool) error { return nil } func (r *edgednsStub) GetRecord(_ string, _ string, _ string) (*dns.RecordBody, error) { resp := &dns.RecordBody{} return resp, nil } func (r *edgednsStub) DeleteRecord(_ *dns.RecordBody, _ string, _ bool) error { return nil } func (r *edgednsStub) UpdateRecord(_ *dns.RecordBody, _ string, _ bool) error { return nil } // Test FetchZones func TestFetchZonesZoneIDFilter(t *testing.T) { stub := newStub() domfilter := &endpoint.DomainFilter{} idfilter := provider.NewZoneIDFilter([]string{"Test"}) c, err := createAkamaiStubProvider(stub, domfilter, idfilter) assert.NoError(t, err) stub.setOutput("zone", []any{"test1.testzone.com", "test2.testzone.com"}) x, _ := c.fetchZones() y, err := json.Marshal(x) require.NoError(t, err) if assert.NotNil(t, y) { assert.JSONEq(t, "{\"zones\":[{\"contractId\":\"contract\",\"zone\":\"test1.testzone.com\"},{\"contractId\":\"contract\",\"zone\":\"test2.testzone.com\"}]}", string(y)) } } func TestFetchZonesEmpty(t *testing.T) { stub := newStub() domfilter := endpoint.NewDomainFilter([]string{"Nonexistent"}) idfilter := provider.NewZoneIDFilter([]string{"Nonexistent"}) c, err := createAkamaiStubProvider(stub, domfilter, idfilter) require.NoError(t, err) stub.setOutput("zone", []any{}) x, _ := c.fetchZones() y, err := json.Marshal(x) require.NoError(t, err) if assert.NotNil(t, y) { assert.JSONEq(t, "{\"zones\":[]}", string(y)) } } // TestAkamaiRecords tests record endpoint func TestAkamaiRecords(t *testing.T) { stub := newStub() domfilter := &endpoint.DomainFilter{} idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) require.NoError(t, err) stub.setOutput("zone", []any{"test1.testzone.com"}) recordsets := make([]any, 0) recordsets = append(recordsets, dns.Recordset{ Name: "www.example.com", Type: endpoint.RecordTypeA, Rdata: []string{"10.0.0.2", "10.0.0.3"}, }) recordsets = append(recordsets, dns.Recordset{ Name: "www.example.com", Type: endpoint.RecordTypeTXT, Rdata: []string{"heritage=external-dns,external-dns/owner=default"}, }) recordsets = append(recordsets, dns.Recordset{ Name: "www.exclude.me", Type: endpoint.RecordTypeA, Rdata: []string{"192.168.0.1", "192.168.0.2"}, }) stub.setOutput("recordset", recordsets) endpoints := make([]*endpoint.Endpoint, 0) endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3")) endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default")) endpoints = append(endpoints, endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "192.168.0.1", "192.168.0.2")) x, _ := c.Records(t.Context()) if assert.NotNil(t, x) { assert.Equal(t, endpoints, x) } } func TestAkamaiRecordsEmpty(t *testing.T) { stub := newStub() domfilter := &endpoint.DomainFilter{} idfilter := provider.NewZoneIDFilter([]string{"Nonexistent"}) c, err := createAkamaiStubProvider(stub, domfilter, idfilter) require.NoError(t, err) stub.setOutput("zone", []any{"test1.testzone.com"}) recordsets := make([]any, 0) stub.setOutput("recordset", recordsets) x, _ := c.Records(t.Context()) assert.Nil(t, x) } func TestAkamaiRecordsFilters(t *testing.T) { stub := newStub() domfilter := endpoint.NewDomainFilter([]string{"www.exclude.me"}) idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) assert.NoError(t, err) stub.setOutput("zone", []any{"www.exclude.me"}) recordsets := make([]any, 0) recordsets = append(recordsets, dns.Recordset{ Name: "www.example.com", Type: endpoint.RecordTypeA, Rdata: []string{"10.0.0.2", "10.0.0.3"}, }) recordsets = append(recordsets, dns.Recordset{ Name: "www.exclude.me", Type: endpoint.RecordTypeA, Rdata: []string{"192.168.0.1", "192.168.0.2"}, }) stub.setOutput("recordset", recordsets) endpoints := make([]*endpoint.Endpoint, 0) endpoints = append(endpoints, endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "192.168.0.1", "192.168.0.2")) x, _ := c.Records(t.Context()) if assert.NotNil(t, x) { assert.Equal(t, endpoints, x) } } // TestCreateRecords tests create function // (p AkamaiProvider) createRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error func TestCreateRecords(t *testing.T) { stub := newStub() domfilter := &endpoint.DomainFilter{} idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) assert.NoError(t, err) zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"} endpoints := make([]*endpoint.Endpoint, 0) endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3")) endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default")) err = c.createRecordsets(zoneNameIDMapper, endpoints) assert.NoError(t, err) } func TestCreateRecordsDomainFilter(t *testing.T) { stub := newStub() domfilter := &endpoint.DomainFilter{} idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) assert.NoError(t, err) zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"} exclude := []*endpoint.Endpoint{ endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"), endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"), } err = c.createRecordsets(zoneNameIDMapper, exclude) assert.NoError(t, err) } // TestDeleteRecords validate delete func TestDeleteRecords(t *testing.T) { stub := newStub() domfilter := &endpoint.DomainFilter{} idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) assert.NoError(t, err) zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"} endpoints := make([]*endpoint.Endpoint, 0) endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3")) endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default")) err = c.deleteRecordsets(zoneNameIDMapper, endpoints) assert.NoError(t, err) } func TestDeleteRecordsDomainFilter(t *testing.T) { stub := newStub() domfilter := endpoint.NewDomainFilter([]string{"example.com"}) idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) require.NoError(t, err) zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"} exclude := []*endpoint.Endpoint{ endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"), endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"), } err = c.deleteRecordsets(zoneNameIDMapper, exclude) assert.NoError(t, err) } // Test record update func func TestUpdateRecords(t *testing.T) { stub := newStub() domfilter := &endpoint.DomainFilter{} idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) require.NoError(t, err) zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"} endpoints := make([]*endpoint.Endpoint, 0) endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3")) endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default")) err = c.updateNewRecordsets(zoneNameIDMapper, endpoints) require.NoError(t, err) } func TestUpdateRecordsDomainFilter(t *testing.T) { stub := newStub() domfilter := endpoint.NewDomainFilter([]string{"example.com"}) idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) require.NoError(t, err) zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"} exclude := []*endpoint.Endpoint{ endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"), endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"), } err = c.updateNewRecordsets(zoneNameIDMapper, exclude) require.NoError(t, err) } func TestAkamaiApplyChanges(t *testing.T) { stub := newStub() domfilter := endpoint.NewDomainFilter([]string{"example.com"}) idfilter := provider.ZoneIDFilter{} c, err := createAkamaiStubProvider(stub, domfilter, idfilter) assert.NoError(t, err) stub.setOutput("zone", []any{"example.com"}) changes := &plan.Changes{} changes.Create = []*endpoint.Endpoint{ {DNSName: "www.example.com", RecordType: "A", Targets: endpoint.Targets{"target"}, RecordTTL: 300}, {DNSName: "test.example.com", RecordType: "A", Targets: endpoint.Targets{"target"}, RecordTTL: 300}, {DNSName: "test.this.example.com", RecordType: "A", Targets: endpoint.Targets{"127.0.0.1"}, RecordTTL: 300}, {DNSName: "www.example.com", RecordType: "TXT", Targets: endpoint.Targets{"heritage=external-dns,external-dns/owner=default"}, RecordTTL: 300}, {DNSName: "test.example.com", RecordType: "TXT", Targets: endpoint.Targets{"heritage=external-dns,external-dns/owner=default"}, RecordTTL: 300}, {DNSName: "test.this.example.com", RecordType: "TXT", Targets: endpoint.Targets{"heritage=external-dns,external-dns/owner=default"}, RecordTTL: 300}, {DNSName: "another.example.com", RecordType: "A", Targets: endpoint.Targets{"target"}}, } changes.Delete = []*endpoint.Endpoint{{DNSName: "delete.example.com", RecordType: "A", Targets: endpoint.Targets{"target"}, RecordTTL: 300}} changes.UpdateOld = []*endpoint.Endpoint{{DNSName: "old.example.com", RecordType: "A", Targets: endpoint.Targets{"target-old"}, RecordTTL: 300}} changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "update.example.com", Targets: endpoint.Targets{"target-new"}, RecordType: "CNAME", RecordTTL: 300}} apply := c.ApplyChanges(t.Context(), changes) assert.NoError(t, apply) } ================================================ FILE: provider/alibabacloud/alibaba_cloud.go ================================================ /* Copyright 2017 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 alibabacloud import ( "context" "fmt" "os" "slices" "sort" "strings" "sync" "time" "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests" "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" "github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz" "github.com/denverdino/aliyungo/metadata" "github.com/goccy/go-yaml" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( defaultTTL = 600 defaultAlibabaCloudPrivateZoneRecordTTL = 60 defaultAlibabaCloudPageSize = 50 nullHostAlibabaCloud = "@" pVTZDoamin = "pvtz.aliyuncs.com" defaultAlibabaCloudRequestScheme = "https" ) // AlibabaCloudDNSAPI is a minimal implementation of DNS API that we actually use, used primarily for unit testing. // See https://help.aliyun.com/document_detail/29739.html for descriptions of all of its methods. type AlibabaCloudDNSAPI interface { AddDomainRecord(request *alidns.AddDomainRecordRequest) (*alidns.AddDomainRecordResponse, error) DeleteDomainRecord(request *alidns.DeleteDomainRecordRequest) (*alidns.DeleteDomainRecordResponse, error) UpdateDomainRecord(request *alidns.UpdateDomainRecordRequest) (*alidns.UpdateDomainRecordResponse, error) DescribeDomainRecords(request *alidns.DescribeDomainRecordsRequest) (*alidns.DescribeDomainRecordsResponse, error) DescribeDomains(request *alidns.DescribeDomainsRequest) (*alidns.DescribeDomainsResponse, error) } // AlibabaCloudPrivateZoneAPI is a minimal implementation of Private Zone API that we actually use, used primarily for unit testing. // See https://help.aliyun.com/document_detail/66234.html for descriptions of all of its methods. type AlibabaCloudPrivateZoneAPI interface { AddZoneRecord(request *pvtz.AddZoneRecordRequest) (*pvtz.AddZoneRecordResponse, error) DeleteZoneRecord(request *pvtz.DeleteZoneRecordRequest) (*pvtz.DeleteZoneRecordResponse, error) UpdateZoneRecord(request *pvtz.UpdateZoneRecordRequest) (*pvtz.UpdateZoneRecordResponse, error) DescribeZoneRecords(request *pvtz.DescribeZoneRecordsRequest) (*pvtz.DescribeZoneRecordsResponse, error) DescribeZones(request *pvtz.DescribeZonesRequest) (*pvtz.DescribeZonesResponse, error) DescribeZoneInfo(request *pvtz.DescribeZoneInfoRequest) (*pvtz.DescribeZoneInfoResponse, error) } // AlibabaCloudProvider implements the DNS provider for Alibaba Cloud. type AlibabaCloudProvider struct { provider.BaseProvider domainFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter // Private Zone only MaxChangeCount int EvaluateTargetHealth bool AssumeRole string vpcID string // Private Zone only dryRun bool dnsClient AlibabaCloudDNSAPI pvtzClient AlibabaCloudPrivateZoneAPI privateZone bool clientLock sync.RWMutex nextExpire time.Time } type alibabaCloudConfig struct { RegionID string `json:"regionId" yaml:"regionId"` AccessKeyID string `json:"accessKeyId" yaml:"accessKeyId"` AccessKeySecret string `json:"accessKeySecret" yaml:"accessKeySecret"` VPCID string `json:"vpcId" yaml:"vpcId"` RoleName string `json:"-" yaml:"-"` // For ECS RAM role only StsToken string `json:"-" yaml:"-"` ExpireTime time.Time `json:"-" yaml:"-"` } // New creates an Alibaba Cloud provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(cfg.AlibabaCloudConfigFile, domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.AlibabaCloudZoneType, cfg.DryRun) } // newAlibabaCloudProvider creates a new Alibaba Cloud provider. // // Returns the provider or an error if a provider could not be created. func newProvider(configFile string, domainFilter *endpoint.DomainFilter, zoneIDFileter provider.ZoneIDFilter, zoneType string, dryRun bool) (*AlibabaCloudProvider, error) { cfg := alibabaCloudConfig{} if configFile != "" { contents, err := os.ReadFile(configFile) if err != nil { return nil, fmt.Errorf("failed to read Alibaba Cloud config file '%s': %w", configFile, err) } err = yaml.Unmarshal(contents, &cfg) if err != nil { return nil, fmt.Errorf("failed to parse Alibaba Cloud config file '%s': %w", configFile, err) } } else { var tmpError error cfg, tmpError = getCloudConfigFromStsToken() if tmpError != nil { return nil, fmt.Errorf("failed to getCloudConfigFromStsToken: %w", tmpError) } } // Public DNS service var dnsClient AlibabaCloudDNSAPI var err error if cfg.RoleName == "" { dnsClient, err = alidns.NewClientWithAccessKey( cfg.RegionID, cfg.AccessKeyID, cfg.AccessKeySecret, ) } else { dnsClient, err = alidns.NewClientWithStsToken( cfg.RegionID, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.StsToken, ) } if err != nil { return nil, fmt.Errorf("failed to create Alibaba Cloud DNS client: %w", err) } // Private DNS service var pvtzClient AlibabaCloudPrivateZoneAPI if cfg.RoleName == "" { pvtzClient, err = pvtz.NewClientWithAccessKey( "cn-hangzhou", // The Private Zone location is fixed cfg.AccessKeyID, cfg.AccessKeySecret, ) } else { pvtzClient, err = pvtz.NewClientWithStsToken( cfg.RegionID, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.StsToken, ) } if err != nil { return nil, err } provider := &AlibabaCloudProvider{ domainFilter: domainFilter, zoneIDFilter: zoneIDFileter, vpcID: cfg.VPCID, dryRun: dryRun, dnsClient: dnsClient, pvtzClient: pvtzClient, privateZone: zoneType == "private", } if cfg.RoleName != "" { provider.setNextExpire(cfg.ExpireTime) go provider.refreshStsToken(1 * time.Second) } return provider, nil } func getCloudConfigFromStsToken() (alibabaCloudConfig, error) { cfg := alibabaCloudConfig{} // Load config from Metadata Service m := metadata.NewMetaData(nil) roleName := "" var err error if roleName, err = m.RoleName(); err != nil { return cfg, fmt.Errorf("failed to get role name from Metadata Service: %w", err) } vpcID, err := m.VpcID() if err != nil { return cfg, fmt.Errorf("failed to get VPC ID from Metadata Service: %w", err) } regionID, err := m.Region() if err != nil { return cfg, fmt.Errorf("failed to get Region ID from Metadata Service: %w", err) } role, err := m.RamRoleToken(roleName) if err != nil { return cfg, fmt.Errorf("failed to get STS Token from Metadata Service: %w", err) } cfg.RegionID = regionID cfg.RoleName = roleName cfg.VPCID = vpcID cfg.AccessKeyID = role.AccessKeyId cfg.AccessKeySecret = role.AccessKeySecret cfg.StsToken = role.SecurityToken cfg.ExpireTime = role.Expiration return cfg, nil } func (p *AlibabaCloudProvider) getDNSClient() AlibabaCloudDNSAPI { p.clientLock.RLock() defer p.clientLock.RUnlock() return p.dnsClient } func (p *AlibabaCloudProvider) getPvtzClient() AlibabaCloudPrivateZoneAPI { p.clientLock.RLock() defer p.clientLock.RUnlock() return p.pvtzClient } func (p *AlibabaCloudProvider) setNextExpire(expireTime time.Time) { p.clientLock.Lock() defer p.clientLock.Unlock() p.nextExpire = expireTime } func (p *AlibabaCloudProvider) refreshStsToken(sleepTime time.Duration) { for { time.Sleep(sleepTime) now := time.Now() utcLocation, err := time.LoadLocation("") if err != nil { log.Errorf("Get utc time error %v", err) continue } nowTime := now.In(utcLocation) p.clientLock.RLock() sleepTime = p.nextExpire.Sub(nowTime) p.clientLock.RUnlock() log.Infof("Distance expiration time %v", sleepTime) if sleepTime < 10*time.Minute { sleepTime = time.Second * 1 } else { sleepTime = 9 * time.Minute log.Info("Next fetch sts sleep interval : ", sleepTime.String()) continue } cfg, err := getCloudConfigFromStsToken() if err != nil { log.Errorf("Failed to getCloudConfigFromStsToken: %v", err) continue } dnsClient, err := alidns.NewClientWithStsToken( cfg.RegionID, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.StsToken, ) if err != nil { log.Errorf("Failed to new client with sts token %v", err) continue } pvtzClient, err := pvtz.NewClientWithStsToken( cfg.RegionID, cfg.AccessKeyID, cfg.AccessKeySecret, cfg.StsToken, ) if err != nil { log.Errorf("Failed to new client with sts token %v", err) continue } log.Infof("Refresh client from sts token, next expire time %v", cfg.ExpireTime) p.clientLock.Lock() p.dnsClient = dnsClient p.pvtzClient = pvtzClient p.nextExpire = cfg.ExpireTime p.clientLock.Unlock() } } // Records gets the current records. // // Returns the current records or an error if the operation failed. func (p *AlibabaCloudProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { if p.privateZone { return p.privateZoneRecords() } else { return p.recordsForDNS() } } // ApplyChanges applies the given changes. // // Returns nil if the operation was successful or an error if the operation failed. func (p *AlibabaCloudProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { if changes == nil || len(changes.Create)+len(changes.Delete)+len(changes.UpdateNew) == 0 { // No op return nil } if p.privateZone { return p.applyChangesForPrivateZone(changes) } return p.applyChangesForDNS(changes) } func (p *AlibabaCloudProvider) getDNSName(rr, domain string) string { if rr == nullHostAlibabaCloud { return domain } return rr + "." + domain } // recordsForDNS gets the current records. // // Returns the current records or an error if the operation failed. func (p *AlibabaCloudProvider) recordsForDNS() ([]*endpoint.Endpoint, error) { records, err := p.records() if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, 0, len(records)) for _, recordList := range p.groupRecords(records) { name := p.getDNSName(recordList[0].RR, recordList[0].DomainName) recordType := recordList[0].Type ttl := recordList[0].TTL var targets []string for _, record := range recordList { target := record.Value if recordType == "TXT" { target = p.unescapeTXTRecordValue(target) } targets = append(targets, target) } ep := endpoint.NewEndpointWithTTL(name, recordType, endpoint.TTL(ttl), targets...) endpoints = append(endpoints, ep) } return endpoints, nil } func getNextPageNumber(pageNumber, totalCount int64) int64 { if pageNumber*defaultAlibabaCloudPageSize >= totalCount { return 0 } return pageNumber + 1 } func (p *AlibabaCloudProvider) getRecordKey(record alidns.Record) string { if record.RR == nullHostAlibabaCloud { return record.Type + ":" + record.DomainName } return record.Type + ":" + record.RR + "." + record.DomainName } func (p *AlibabaCloudProvider) getRecordKeyByEndpoint(endpoint *endpoint.Endpoint) string { return endpoint.RecordType + ":" + endpoint.DNSName } func (p *AlibabaCloudProvider) groupRecords(records []alidns.Record) map[string][]alidns.Record { endpointMap := make(map[string][]alidns.Record) for _, record := range records { key := p.getRecordKey(record) recordList := endpointMap[key] endpointMap[key] = append(recordList, record) } return endpointMap } func (p *AlibabaCloudProvider) records() ([]alidns.Record, error) { log.Infof("Retrieving Alibaba Cloud DNS Domain Records") var results []alidns.Record hostedZoneDomains, err := p.getDomainList() if err != nil { return results, fmt.Errorf("getting domain list: %w", err) } if !p.domainFilter.IsConfigured() { for _, zoneDomain := range hostedZoneDomains { domainRecords, err := p.getDomainRecords(zoneDomain) if err != nil { return nil, fmt.Errorf("getDomainRecords %q: %w", zoneDomain, err) } results = append(results, domainRecords...) } } else { for _, domainName := range p.domainFilter.Filters { _, domainName = p.splitDNSName(domainName, hostedZoneDomains) tmpResults, err := p.getDomainRecords(domainName) if err != nil { log.Errorf("getDomainRecords %s error %v", domainName, err) continue } results = append(results, tmpResults...) } } log.Infof("Found %d Alibaba Cloud DNS record(s).", len(results)) return results, nil } func (p *AlibabaCloudProvider) getDomainList() ([]string, error) { var domainNames []string request := alidns.CreateDescribeDomainsRequest() request.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize) request.PageNumber = "1" request.Scheme = defaultAlibabaCloudRequestScheme for { resp, err := p.dnsClient.DescribeDomains(request) if err != nil { log.Errorf("Failed to describe domains for Alibaba Cloud DNS: %v", err) return nil, err } for _, tmpDomain := range resp.Domains.Domain { domainNames = append(domainNames, tmpDomain.DomainName) } nextPage := getNextPageNumber(resp.PageNumber, resp.TotalCount) if nextPage == 0 { break } else { request.PageNumber = requests.NewInteger64(nextPage) } } return domainNames, nil } func (p *AlibabaCloudProvider) getDomainRecords(domainName string) ([]alidns.Record, error) { var results []alidns.Record request := alidns.CreateDescribeDomainRecordsRequest() request.DomainName = domainName request.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize) request.PageNumber = "1" request.Scheme = defaultAlibabaCloudRequestScheme for { response, err := p.getDNSClient().DescribeDomainRecords(request) if err != nil { log.Errorf("Failed to describe domain records for Alibaba Cloud DNS: %v", err) return nil, err } for _, record := range response.DomainRecords.Record { domainName := record.RR + "." + record.DomainName recordType := record.Type if !p.domainFilter.Match(domainName) { continue } if !provider.SupportedRecordType(recordType) { continue } // TODO filter Locked record results = append(results, record) } nextPage := getNextPageNumber(response.PageNumber, response.TotalCount) if nextPage == 0 { break } else { request.PageNumber = requests.NewInteger64(nextPage) } } return results, nil } func (p *AlibabaCloudProvider) applyChangesForDNS(changes *plan.Changes) error { log.Infof("ApplyChanges to Alibaba Cloud DNS: %++v", *changes) records, err := p.records() if err != nil { return err } recordMap := p.groupRecords(records) hostedZoneDomains, err := p.getDomainList() if err != nil { return fmt.Errorf("getting domain list: %w", err) } p.createRecords(changes.Create, hostedZoneDomains) p.deleteRecords(recordMap, changes.Delete) p.updateRecords(recordMap, changes.UpdateNew, hostedZoneDomains) return nil } func (p *AlibabaCloudProvider) escapeTXTRecordValue(value string) string { // For unsupported chars return value } func (p *AlibabaCloudProvider) unescapeTXTRecordValue(value string) string { if strings.HasPrefix(value, "heritage=") { return fmt.Sprintf("\"%s\"", strings.ReplaceAll(value, ";", ",")) } return value } func (p *AlibabaCloudProvider) createRecord(endpoint *endpoint.Endpoint, target string, hostedZoneDomains []string) error { if len(hostedZoneDomains) == 0 { log.Errorf("Failed to create %s record named '%s' to '%s' for Alibaba Cloud DNS: zone not found", endpoint.RecordType, endpoint.DNSName, target) return fmt.Errorf("zone not found") } rr, domain := p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if domain == "" { log.Errorf("Failed to create %s record named '%s' to '%s' for Alibaba Cloud DNS: no corresponding DNS zone found for this domain '%s'", endpoint.RecordType, endpoint.DNSName, target, endpoint.DNSName) return fmt.Errorf("no corresponding DNS zone found for this domain") } request := alidns.CreateAddDomainRecordRequest() request.DomainName = domain request.Type = endpoint.RecordType request.RR = rr request.Scheme = defaultAlibabaCloudRequestScheme ttl := int(endpoint.RecordTTL) if ttl != 0 { request.TTL = requests.NewInteger(ttl) } if endpoint.RecordType == "TXT" { target = p.escapeTXTRecordValue(target) } request.Value = target if p.dryRun { log.Infof("Dry run: Create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud DNS", endpoint.RecordType, endpoint.DNSName, target, ttl) return nil } response, err := p.getDNSClient().AddDomainRecord(request) if err == nil { log.Infof("Create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud DNS: Record ID=%s", endpoint.RecordType, endpoint.DNSName, target, ttl, response.RecordId) } else { log.Errorf("Failed to create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud DNS: %v", endpoint.RecordType, endpoint.DNSName, target, ttl, err) } return err } func (p *AlibabaCloudProvider) createRecords(endpoints []*endpoint.Endpoint, hostedZoneDomains []string) { for _, endpoint := range endpoints { for _, target := range endpoint.Targets { p.createRecord(endpoint, target, hostedZoneDomains) } } } func (p *AlibabaCloudProvider) deleteRecord(recordID string) error { if p.dryRun { log.Infof("Dry run: Delete record id '%s' in Alibaba Cloud DNS", recordID) return nil } request := alidns.CreateDeleteDomainRecordRequest() request.RecordId = recordID request.Scheme = defaultAlibabaCloudRequestScheme response, err := p.getDNSClient().DeleteDomainRecord(request) if err == nil { log.Infof("Delete record id %s in Alibaba Cloud DNS", response.RecordId) } else { log.Errorf("Failed to delete record '%s' in Alibaba Cloud DNS: %v", response.RecordId, err) } return err } func (p *AlibabaCloudProvider) updateRecord(record alidns.Record, endpoint *endpoint.Endpoint) error { request := alidns.CreateUpdateDomainRecordRequest() request.RecordId = record.RecordId request.RR = record.RR request.Type = record.Type request.Value = record.Value request.Scheme = defaultAlibabaCloudRequestScheme ttl := int(endpoint.RecordTTL) if ttl != 0 { request.TTL = requests.NewInteger(ttl) } response, err := p.getDNSClient().UpdateDomainRecord(request) if err == nil { log.Infof("Update record id '%s' in Alibaba Cloud DNS", response.RecordId) } else { log.Errorf("Failed to update record '%s' in Alibaba Cloud DNS: %v", response.RecordId, err) } return err } func (p *AlibabaCloudProvider) deleteRecords(recordMap map[string][]alidns.Record, endpoints []*endpoint.Endpoint) { for _, endpoint := range endpoints { key := p.getRecordKeyByEndpoint(endpoint) records := recordMap[key] found := false for _, record := range records { value := record.Value if record.Type == "TXT" { value = p.unescapeTXTRecordValue(value) } if slices.Contains(endpoint.Targets, value) { p.deleteRecord(record.RecordId) found = true } } if !found { log.Errorf("Failed to find %s record named '%s' to delete for Alibaba Cloud DNS", endpoint.RecordType, endpoint.DNSName) } } } func (p *AlibabaCloudProvider) equals(record alidns.Record, endpoint *endpoint.Endpoint) bool { ttl1 := record.TTL if ttl1 == defaultTTL { ttl1 = 0 } ttl2 := int64(endpoint.RecordTTL) if ttl2 == defaultTTL { ttl2 = 0 } return ttl1 == ttl2 } func (p *AlibabaCloudProvider) updateRecords(recordMap map[string][]alidns.Record, endpoints []*endpoint.Endpoint, hostedZoneDomains []string) { for _, endpoint := range endpoints { key := p.getRecordKeyByEndpoint(endpoint) records := recordMap[key] for _, record := range records { value := record.Value if record.Type == "TXT" { value = p.unescapeTXTRecordValue(value) } found := false for _, target := range endpoint.Targets { // Find matched record to delete if value == target { found = true } } if found { if !p.equals(record, endpoint) { // Update record p.updateRecord(record, endpoint) } } else { p.deleteRecord(record.RecordId) } } for _, target := range endpoint.Targets { if endpoint.RecordType == "TXT" { target = p.escapeTXTRecordValue(target) } found := false for _, record := range records { // Find matched record to delete if record.Value == target { found = true } } if !found { p.createRecord(endpoint, target, hostedZoneDomains) } } } } func (p *AlibabaCloudProvider) splitDNSName(dnsName string, hostedZoneDomains []string) (string, string) { name := strings.TrimSuffix(dnsName, ".") // sort zones by dot count; make sure subdomains sort earlier sort.Slice(hostedZoneDomains, func(i, j int) bool { return strings.Count(hostedZoneDomains[i], ".") > strings.Count(hostedZoneDomains[j], ".") }) var rr, domain string for _, filter := range hostedZoneDomains { if strings.HasSuffix(name, "."+filter) { rr = name[0 : len(name)-len(filter)-1] domain = filter break } else if name == filter { domain = filter rr = "" } } if rr == "" { rr = nullHostAlibabaCloud } return rr, domain } func (p *AlibabaCloudProvider) matchVPC(zoneID string) bool { request := pvtz.CreateDescribeZoneInfoRequest() request.ZoneId = zoneID request.Domain = pVTZDoamin request.Scheme = defaultAlibabaCloudRequestScheme response, err := p.getPvtzClient().DescribeZoneInfo(request) if err != nil { log.Errorf("Failed to describe zone info %s in Alibaba Cloud DNS: %v", zoneID, err) return false } foundVPC := false for _, vpc := range response.BindVpcs.Vpc { if vpc.VpcId == p.vpcID { foundVPC = true break } } return foundVPC } func (p *AlibabaCloudProvider) privateZones() ([]pvtz.Zone, error) { var zones []pvtz.Zone request := pvtz.CreateDescribeZonesRequest() request.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize) request.PageNumber = "1" request.Domain = pVTZDoamin request.Scheme = defaultAlibabaCloudRequestScheme for { response, err := p.getPvtzClient().DescribeZones(request) if err != nil { log.Errorf("Failed to describe zones in Alibaba Cloud DNS: %v", err) return nil, err } for _, zone := range response.Zones.Zone { log.Infof("PrivateZones zone: %++v", zone) if !p.zoneIDFilter.Match(zone.ZoneId) { continue } if !p.domainFilter.Match(zone.ZoneName) { continue } if !p.matchVPC(zone.ZoneId) { continue } zones = append(zones, zone) } nextPage := getNextPageNumber(int64(response.PageNumber), int64(response.TotalItems)) if nextPage == 0 { break } else { request.PageNumber = requests.NewInteger64(nextPage) } } return zones, nil } type alibabaPrivateZone struct { pvtz.Zone records []pvtz.Record } func (p *AlibabaCloudProvider) getPrivateZones() (map[string]*alibabaPrivateZone, error) { log.Infof("Retrieving Alibaba Cloud Private Zone records") result := make(map[string]*alibabaPrivateZone) recordsCount := 0 zones, err := p.privateZones() if err != nil { return nil, err } for _, zone := range zones { request := pvtz.CreateDescribeZoneRecordsRequest() request.ZoneId = zone.ZoneId request.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize) request.PageNumber = "1" request.Domain = pVTZDoamin request.Scheme = defaultAlibabaCloudRequestScheme var records []pvtz.Record for { response, err := p.getPvtzClient().DescribeZoneRecords(request) if err != nil { log.Errorf("Failed to describe zone record '%s' in Alibaba Cloud DNS: %v", zone.ZoneId, err) return nil, err } for _, record := range response.Records.Record { recordType := record.Type if !provider.SupportedRecordType(recordType) { continue } // TODO filter Locked records = append(records, record) } nextPage := getNextPageNumber(int64(response.PageNumber), int64(response.TotalItems)) if nextPage == 0 { break } else { request.PageNumber = requests.NewInteger64(nextPage) } } privateZone := alibabaPrivateZone{ Zone: zone, records: records, } recordsCount += len(records) result[zone.ZoneName] = &privateZone } log.Infof("Found %d Alibaba Cloud Private Zone record(s).", recordsCount) return result, nil } func (p *AlibabaCloudProvider) groupPrivateZoneRecords(zone *alibabaPrivateZone) map[string][]pvtz.Record { endpointMap := make(map[string][]pvtz.Record) for _, record := range zone.records { key := record.Type + ":" + record.Rr recordList := endpointMap[key] endpointMap[key] = append(recordList, record) } return endpointMap } // recordsForPrivateZone gets the current records. // // Returns the current records or an error if the operation failed. func (p *AlibabaCloudProvider) privateZoneRecords() ([]*endpoint.Endpoint, error) { zones, err := p.getPrivateZones() if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, 0) for _, zone := range zones { recordMap := p.groupPrivateZoneRecords(zone) for _, recordList := range recordMap { name := p.getDNSName(recordList[0].Rr, zone.ZoneName) recordType := recordList[0].Type ttl := recordList[0].Ttl if ttl == defaultAlibabaCloudPrivateZoneRecordTTL { ttl = 0 } var targets []string for _, record := range recordList { target := record.Value if recordType == "TXT" { target = p.unescapeTXTRecordValue(target) } targets = append(targets, target) } ep := endpoint.NewEndpointWithTTL(name, recordType, endpoint.TTL(ttl), targets...) endpoints = append(endpoints, ep) } } return endpoints, nil } func (p *AlibabaCloudProvider) createPrivateZoneRecord(zones map[string]*alibabaPrivateZone, endpoint *endpoint.Endpoint, target string) error { rr, domain := p.splitDNSName(endpoint.DNSName, keys(zones)) zone := zones[domain] if zone == nil { err := fmt.Errorf("failed to find private zone '%s'", domain) log.Errorf("Failed to create %s record named '%s' to '%s' for Alibaba Cloud Private Zone: %v", endpoint.RecordType, endpoint.DNSName, target, err) return err } request := pvtz.CreateAddZoneRecordRequest() request.ZoneId = zone.ZoneId request.Type = endpoint.RecordType request.Rr = rr request.Domain = pVTZDoamin request.Scheme = defaultAlibabaCloudRequestScheme ttl := int(endpoint.RecordTTL) if ttl != 0 { request.Ttl = requests.NewInteger(ttl) } if endpoint.RecordType == "TXT" { target = p.escapeTXTRecordValue(target) } request.Value = target if p.dryRun { log.Infof("Dry run: Create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud Private Zone", endpoint.RecordType, endpoint.DNSName, target, ttl) return nil } response, err := p.getPvtzClient().AddZoneRecord(request) if err == nil { log.Infof("Create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud Private Zone: Record ID=%d", endpoint.RecordType, endpoint.DNSName, target, ttl, response.RecordId) } else { log.Errorf("Failed to create %s record named '%s' to '%s' with ttl %d for Alibaba Cloud Private Zone: %v", endpoint.RecordType, endpoint.DNSName, target, ttl, err) } return err } func (p *AlibabaCloudProvider) createPrivateZoneRecords(zones map[string]*alibabaPrivateZone, endpoints []*endpoint.Endpoint) { for _, endpoint := range endpoints { for _, target := range endpoint.Targets { _ = p.createPrivateZoneRecord(zones, endpoint, target) } } } func (p *AlibabaCloudProvider) deletePrivateZoneRecord(recordID int64) error { if p.dryRun { log.Infof("Dry run: Delete record id '%d' in Alibaba Cloud Private Zone", recordID) } request := pvtz.CreateDeleteZoneRecordRequest() request.RecordId = requests.NewInteger64(recordID) request.Domain = pVTZDoamin request.Scheme = defaultAlibabaCloudRequestScheme response, err := p.getPvtzClient().DeleteZoneRecord(request) if err == nil { log.Infof("Delete record id '%d' in Alibaba Cloud Private Zone", response.RecordId) } else { log.Errorf("Failed to delete record %d in Alibaba Cloud Private Zone: %v", response.RecordId, err) } return err } func (p *AlibabaCloudProvider) deletePrivateZoneRecords(zones map[string]*alibabaPrivateZone, endpoints []*endpoint.Endpoint) { zoneNames := keys(zones) for _, endpoint := range endpoints { rr, domain := p.splitDNSName(endpoint.DNSName, zoneNames) zone := zones[domain] if zone == nil { log.Errorf("Failed to delete %s record named '%s' for Alibaba Cloud Private Zone: failed to find private zone '%s'", endpoint.RecordType, endpoint.DNSName, domain) continue } found := false for _, record := range zone.records { if rr == record.Rr && endpoint.RecordType == record.Type { value := record.Value if record.Type == "TXT" { value = p.unescapeTXTRecordValue(value) } if slices.Contains(endpoint.Targets, value) { p.deletePrivateZoneRecord(record.RecordId) found = true } } } if !found { log.Errorf("Failed to find %s record named '%s' to delete for Alibaba Cloud Private Zone", endpoint.RecordType, endpoint.DNSName) } } } // ApplyChanges applies the given changes. // // Returns nil if the operation was successful or an error if the operation failed. func (p *AlibabaCloudProvider) applyChangesForPrivateZone(changes *plan.Changes) error { log.Infof("ApplyChanges to Alibaba Cloud Private Zone: %++v", *changes) zones, err := p.getPrivateZones() if err != nil { return err } for zoneName, zone := range zones { log.Debugf("%s: %++v", zoneName, zone) } p.createPrivateZoneRecords(zones, changes.Create) p.deletePrivateZoneRecords(zones, changes.Delete) p.updatePrivateZoneRecords(zones, changes.UpdateNew) return nil } func (p *AlibabaCloudProvider) updatePrivateZoneRecord(record pvtz.Record, endpoint *endpoint.Endpoint) error { request := pvtz.CreateUpdateZoneRecordRequest() request.RecordId = requests.NewInteger64(record.RecordId) request.Rr = record.Rr request.Type = record.Type request.Value = record.Value request.Domain = pVTZDoamin request.Scheme = defaultAlibabaCloudRequestScheme ttl := int(endpoint.RecordTTL) if ttl != 0 { request.Ttl = requests.NewInteger(ttl) } response, err := p.getPvtzClient().UpdateZoneRecord(request) if err == nil { log.Infof("Update record id '%d' in Alibaba Cloud Private Zone", response.RecordId) } else { log.Errorf("Failed to update record '%d' in Alibaba Cloud Private Zone: %v", response.RecordId, err) } return err } func (p *AlibabaCloudProvider) equalsPrivateZone(record pvtz.Record, endpoint *endpoint.Endpoint) bool { ttl1 := record.Ttl if ttl1 == defaultAlibabaCloudPrivateZoneRecordTTL { ttl1 = 0 } ttl2 := int(endpoint.RecordTTL) if ttl2 == defaultAlibabaCloudPrivateZoneRecordTTL { ttl2 = 0 } return ttl1 == ttl2 } func (p *AlibabaCloudProvider) updatePrivateZoneRecords(zones map[string]*alibabaPrivateZone, endpoints []*endpoint.Endpoint) { zoneNames := keys(zones) for _, endpoint := range endpoints { rr, domain := p.splitDNSName(endpoint.DNSName, zoneNames) zone := zones[domain] if zone == nil { log.Errorf("Failed to update %s record named '%s' for Alibaba Cloud Private Zone: failed to find private zone '%s'", endpoint.RecordType, endpoint.DNSName, domain) continue } for _, record := range zone.records { if record.Rr != rr || record.Type != endpoint.RecordType { continue } value := record.Value if record.Type == "TXT" { value = p.unescapeTXTRecordValue(value) } found := slices.Contains(endpoint.Targets, value) if found { if !p.equalsPrivateZone(record, endpoint) { // Update record p.updatePrivateZoneRecord(record, endpoint) } } else { p.deletePrivateZoneRecord(record.RecordId) } } for _, target := range endpoint.Targets { if endpoint.RecordType == "TXT" { target = p.escapeTXTRecordValue(target) } found := false for _, record := range zone.records { if record.Rr != rr || record.Type != endpoint.RecordType { continue } // Find matched record to delete if record.Value == target { found = true break } } if !found { p.createPrivateZoneRecord(zones, endpoint, target) } } } } func keys[T any](value map[string]T) []string { var results []string for k := range value { results = append(results, k) } return results } ================================================ FILE: provider/alibabacloud/alibaba_cloud_test.go ================================================ /* Copyright 2017 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 alibabacloud import ( "testing" "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" "github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) type MockAlibabaCloudDNSAPI struct { records []alidns.Record } func NewMockAlibabaCloudDNSAPI() *MockAlibabaCloudDNSAPI { api := MockAlibabaCloudDNSAPI{} api.records = []alidns.Record{ { RecordId: "1", DomainName: "container-service.top", Type: "A", TTL: 300, RR: "abc", Value: "1.2.3.4", }, { RecordId: "2", DomainName: "container-service.top", Type: "TXT", TTL: 300, RR: "abc", Value: "heritage=external-dns;external-dns/owner=default", }, } return &api } func (m *MockAlibabaCloudDNSAPI) AddDomainRecord(request *alidns.AddDomainRecordRequest) (*alidns.AddDomainRecordResponse, error) { ttl, _ := request.TTL.GetValue() m.records = append(m.records, alidns.Record{ RecordId: "3", DomainName: request.DomainName, Type: request.Type, TTL: int64(ttl), RR: request.RR, Value: request.Value, }) return alidns.CreateAddDomainRecordResponse(), nil } func (m *MockAlibabaCloudDNSAPI) DeleteDomainRecord(request *alidns.DeleteDomainRecordRequest) (*alidns.DeleteDomainRecordResponse, error) { var result []alidns.Record for _, record := range m.records { if record.RecordId != request.RecordId { result = append(result, record) } } m.records = result response := alidns.CreateDeleteDomainRecordResponse() response.RecordId = request.RecordId return response, nil } func (m *MockAlibabaCloudDNSAPI) UpdateDomainRecord(request *alidns.UpdateDomainRecordRequest) (*alidns.UpdateDomainRecordResponse, error) { ttl, _ := request.TTL.GetValue64() for i := range m.records { if m.records[i].RecordId == request.RecordId { m.records[i].TTL = ttl } } response := alidns.CreateUpdateDomainRecordResponse() response.RecordId = request.RecordId return response, nil } func (m *MockAlibabaCloudDNSAPI) DescribeDomains(_ *alidns.DescribeDomainsRequest) (*alidns.DescribeDomainsResponse, error) { var result alidns.DomainsInDescribeDomains for _, record := range m.records { domain := alidns.Domain{} domain.DomainName = record.DomainName result.Domain = append(result.Domain, alidns.DomainInDescribeDomains{ DomainName: domain.DomainName, }) } response := alidns.CreateDescribeDomainsResponse() response.Domains = result return response, nil } func (m *MockAlibabaCloudDNSAPI) DescribeDomainRecords(request *alidns.DescribeDomainRecordsRequest) (*alidns.DescribeDomainRecordsResponse, error) { var result []alidns.Record for _, record := range m.records { if record.DomainName == request.DomainName { result = append(result, record) } } response := alidns.CreateDescribeDomainRecordsResponse() response.DomainRecords.Record = result return response, nil } type MockAlibabaCloudPrivateZoneAPI struct { zone pvtz.Zone records []pvtz.Record } func NewMockAlibabaCloudPrivateZoneAPI() *MockAlibabaCloudPrivateZoneAPI { vpc := pvtz.Vpc{ RegionId: "cn-beijing", VpcId: "vpc-xxxxxx", } api := MockAlibabaCloudPrivateZoneAPI{zone: pvtz.Zone{ ZoneId: "test-zone", ZoneName: "container-service.top", Vpcs: pvtz.Vpcs{ Vpc: []pvtz.Vpc{vpc}, }, }} api.records = []pvtz.Record{ { RecordId: 1, Type: "A", Ttl: 300, Rr: "abc", Value: "1.2.3.4", }, { RecordId: 2, Type: "TXT", Ttl: 300, Rr: "abc", Value: "heritage=external-dns;external-dns/owner=default", }, } return &api } func (m *MockAlibabaCloudPrivateZoneAPI) AddZoneRecord(request *pvtz.AddZoneRecordRequest) (*pvtz.AddZoneRecordResponse, error) { ttl, _ := request.Ttl.GetValue() m.records = append(m.records, pvtz.Record{ RecordId: 3, Type: request.Type, Ttl: ttl, Rr: request.Rr, Value: request.Value, }) return pvtz.CreateAddZoneRecordResponse(), nil } func (m *MockAlibabaCloudPrivateZoneAPI) DeleteZoneRecord(request *pvtz.DeleteZoneRecordRequest) (*pvtz.DeleteZoneRecordResponse, error) { recordID, _ := request.RecordId.GetValue64() var result []pvtz.Record for _, record := range m.records { if record.RecordId != recordID { result = append(result, record) } } m.records = result return pvtz.CreateDeleteZoneRecordResponse(), nil } func (m *MockAlibabaCloudPrivateZoneAPI) UpdateZoneRecord(request *pvtz.UpdateZoneRecordRequest) (*pvtz.UpdateZoneRecordResponse, error) { recordID, _ := request.RecordId.GetValue64() ttl, _ := request.Ttl.GetValue() for i := range m.records { if m.records[i].RecordId == recordID { m.records[i].Ttl = ttl } } return pvtz.CreateUpdateZoneRecordResponse(), nil } func (m *MockAlibabaCloudPrivateZoneAPI) DescribeZoneRecords(_ *pvtz.DescribeZoneRecordsRequest) (*pvtz.DescribeZoneRecordsResponse, error) { response := pvtz.CreateDescribeZoneRecordsResponse() response.Records.Record = append(response.Records.Record, m.records...) return response, nil } func (m *MockAlibabaCloudPrivateZoneAPI) DescribeZones(_ *pvtz.DescribeZonesRequest) (*pvtz.DescribeZonesResponse, error) { response := pvtz.CreateDescribeZonesResponse() response.Zones.Zone = append(response.Zones.Zone, m.zone) return response, nil } func (m *MockAlibabaCloudPrivateZoneAPI) DescribeZoneInfo(_ *pvtz.DescribeZoneInfoRequest) (*pvtz.DescribeZoneInfoResponse, error) { response := pvtz.CreateDescribeZoneInfoResponse() response.ZoneId = m.zone.ZoneId response.ZoneName = m.zone.ZoneName response.BindVpcs = pvtz.BindVpcsInDescribeZoneInfo{Vpc: make([]pvtz.VpcInDescribeZoneInfo, len(m.zone.Vpcs.Vpc))} for idx, vpc := range m.zone.Vpcs.Vpc { response.BindVpcs.Vpc[idx] = pvtz.VpcInDescribeZoneInfo{VpcName: vpc.VpcName, VpcId: vpc.VpcId, VpcType: vpc.VpcType, RegionName: vpc.RegionName, RegionId: vpc.RegionId} } return response, nil } func newTestAlibabaCloudProvider(private bool) *AlibabaCloudProvider { cfg := alibabaCloudConfig{ VPCID: "vpc-xxxxxx", } domainFilterTest := endpoint.NewDomainFilter([]string{"container-service.top.", "example.org"}) return &AlibabaCloudProvider{ domainFilter: domainFilterTest, vpcID: cfg.VPCID, dryRun: false, dnsClient: NewMockAlibabaCloudDNSAPI(), pvtzClient: NewMockAlibabaCloudPrivateZoneAPI(), privateZone: private, } } func TestAlibabaCloudPrivateProvider_Records(t *testing.T) { p := newTestAlibabaCloudProvider(true) endpoints, err := p.Records(t.Context()) if err != nil { t.Errorf("Failed to get records: %v", err) } else { if len(endpoints) != 2 { t.Errorf("Incorrect number of records: %d", len(endpoints)) } for _, ep := range endpoints { t.Logf("Endpoint for %++v", *ep) } } } func TestAlibabaCloudProvider_Records(t *testing.T) { p := newTestAlibabaCloudProvider(false) endpoints, err := p.Records(t.Context()) if err != nil { t.Errorf("Failed to get records: %v", err) } else { if len(endpoints) != 2 { t.Errorf("Incorrect number of records: %d", len(endpoints)) } for _, ep := range endpoints { t.Logf("Endpoint for %++v", *ep) } } } func TestAlibabaCloudProvider_ApplyChanges(t *testing.T) { p := newTestAlibabaCloudProvider(false) defaultTtlPlan := &endpoint.Endpoint{ DNSName: "ttl.container-service.top", RecordType: "A", RecordTTL: defaultTTL, Targets: endpoint.NewTargets("4.3.2.1"), } changes := plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "xyz.container-service.top", RecordType: "A", RecordTTL: 300, Targets: endpoint.NewTargets("4.3.2.1"), }, defaultTtlPlan, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "abc.container-service.top", RecordType: "A", RecordTTL: 500, Targets: endpoint.NewTargets("1.2.3.4", "5.6.7.8"), }, }, Delete: []*endpoint.Endpoint{ { DNSName: "abc.container-service.top", RecordType: "TXT", RecordTTL: 300, Targets: endpoint.NewTargets("\"heritage=external-dns,external-dns/owner=default\""), }, }, } ctx := t.Context() err := p.ApplyChanges(ctx, &changes) assert.NoError(t, err) endpoints, err := p.Records(ctx) if err != nil { t.Errorf("Failed to get records: %v", err) } else { if len(endpoints) != 3 { t.Errorf("Incorrect number of records: %d", len(endpoints)) } for _, ep := range endpoints { t.Logf("Endpoint for %++v", *ep) } } for _, ep := range endpoints { if ep.DNSName == defaultTtlPlan.DNSName { if ep.RecordTTL != defaultTtlPlan.RecordTTL { t.Error("default ttl execute error") } } } } func TestAlibabaCloudProvider_ApplyChanges_HaveNoDefinedZoneDomain(t *testing.T) { p := newTestAlibabaCloudProvider(false) defaultTtlPlan := &endpoint.Endpoint{ DNSName: "ttl.container-service.top", RecordType: "A", RecordTTL: defaultTTL, Targets: endpoint.NewTargets("4.3.2.1"), } changes := plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "www.example.com", // no found this zone by API: DescribeDomains RecordType: "A", RecordTTL: 300, Targets: endpoint.NewTargets("9.9.9.9"), }, defaultTtlPlan, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "abc.container-service.top", RecordType: "A", RecordTTL: 500, Targets: endpoint.NewTargets("1.2.3.4", "5.6.7.8"), }, }, Delete: []*endpoint.Endpoint{ { DNSName: "abc.container-service.top", RecordType: "TXT", RecordTTL: 300, Targets: endpoint.NewTargets("\"heritage=external-dns,external-dns/owner=default\""), }, }, } ctx := t.Context() err := p.ApplyChanges(ctx, &changes) assert.NoError(t, err) endpoints, err := p.Records(ctx) if err != nil { t.Errorf("Failed to get records: %v", err) } else { if len(endpoints) != 2 { t.Errorf("Incorrect number of records: %d", len(endpoints)) } for _, ep := range endpoints { t.Logf("Endpoint for %++v", *ep) } } for _, ep := range endpoints { if ep.DNSName == defaultTtlPlan.DNSName { if ep.RecordTTL != defaultTtlPlan.RecordTTL { t.Error("default ttl execute error") } } } } func TestAlibabaCloudProvider_Records_PrivateZone(t *testing.T) { p := newTestAlibabaCloudProvider(true) endpoints, err := p.Records(t.Context()) if err != nil { t.Errorf("Failed to get records: %v", err) } else { if len(endpoints) != 2 { t.Errorf("Incorrect number of records: %d", len(endpoints)) } for _, ep := range endpoints { t.Logf("Endpoint for %++v", *ep) } } } func TestAlibabaCloudProvider_ApplyChanges_PrivateZone(t *testing.T) { p := newTestAlibabaCloudProvider(true) changes := plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "xyz.container-service.top", RecordType: "A", RecordTTL: 300, Targets: endpoint.NewTargets("4.3.2.1"), }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "abc.container-service.top", RecordType: "A", RecordTTL: 500, Targets: endpoint.NewTargets("1.2.3.4", "5.6.7.8"), }, }, Delete: []*endpoint.Endpoint{ { DNSName: "abc.container-service.top", RecordType: "TXT", RecordTTL: 300, Targets: endpoint.NewTargets("\"heritage=external-dns,external-dns/owner=default\""), }, }, } ctx := t.Context() err := p.ApplyChanges(ctx, &changes) assert.NoError(t, err) endpoints, err := p.Records(ctx) if err != nil { t.Errorf("Failed to get records: %v", err) } else { if len(endpoints) != 2 { t.Errorf("Incorrect number of records: %d", len(endpoints)) } for _, ep := range endpoints { t.Logf("Endpoint for %++v", *ep) } } } func TestAlibabaCloudProvider_splitDNSName(t *testing.T) { p := newTestAlibabaCloudProvider(false) endpoint := &endpoint.Endpoint{} hostedZoneDomains := []string{"container-service.top", "example.org"} var emptyZoneDomains []string endpoint.DNSName = "www.example.org" rr, domain := p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if rr != "www" || domain != "example.org" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = ".example.org" rr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if rr != "@" || domain != "example.org" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = "www" rr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if rr != "@" || domain != "" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = "" rr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if rr != "@" || domain != "" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = "_30000._tcp.container-service.top" rr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if rr != "_30000._tcp" || domain != "container-service.top" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = "container-service.top" rr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if rr != "@" || domain != "container-service.top" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = "a.b.container-service.top" rr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if rr != "a.b" || domain != "container-service.top" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = "a.b.c.container-service.top" rr, domain = p.splitDNSName(endpoint.DNSName, hostedZoneDomains) if rr != "a.b.c" || domain != "container-service.top" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = "a.b.c.container-service.top" rr, domain = p.splitDNSName(endpoint.DNSName, []string{"c.container-service.top"}) if rr != "a.b" || domain != "c.container-service.top" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } endpoint.DNSName = "a.b.c.container-service.top" rr, domain = p.splitDNSName(endpoint.DNSName, []string{"container-service.top", "c.container-service.top"}) if rr != "a.b" || domain != "c.container-service.top" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } rr, domain = p.splitDNSName(endpoint.DNSName, emptyZoneDomains) if rr != "@" || domain != "" { t.Errorf("Failed to splitDNSName with emptyZoneDomains for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } rr, domain = p.splitDNSName(endpoint.DNSName, []string{"example.com"}) if rr != "@" || domain != "" { t.Errorf("Failed to splitDNSName for %s: rr=%s, domain=%s", endpoint.DNSName, rr, domain) } } func TestAlibabaCloudProvider_TXTEndpoint(t *testing.T) { p := newTestAlibabaCloudProvider(false) const recordValue = "heritage=external-dns,external-dns/owner=default" const endpointTarget = "\"heritage=external-dns,external-dns/owner=default\"" if p.escapeTXTRecordValue(endpointTarget) != endpointTarget { t.Errorf("Failed to escapeTXTRecordValue: %s", p.escapeTXTRecordValue(endpointTarget)) } if p.unescapeTXTRecordValue(recordValue) != endpointTarget { t.Errorf("Failed to unescapeTXTRecordValue: %s", p.unescapeTXTRecordValue(recordValue)) } } // TestAlibabaCloudProvider_TXTEndpoint_PrivateZone func TestAlibabaCloudProvider_TXTEndpoint_PrivateZone(t *testing.T) { p := newTestAlibabaCloudProvider(true) const recordValue = "heritage=external-dns,external-dns/owner=default" const endpointTarget = "\"heritage=external-dns,external-dns/owner=default\"" if p.escapeTXTRecordValue(endpointTarget) != endpointTarget { t.Errorf("Failed to escapeTXTRecordValue: %s", p.escapeTXTRecordValue(endpointTarget)) } if p.unescapeTXTRecordValue(recordValue) != endpointTarget { t.Errorf("Failed to unescapeTXTRecordValue: %s", p.unescapeTXTRecordValue(recordValue)) } } ================================================ FILE: provider/aws/aws.go ================================================ /* Copyright 2017 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 aws import ( "context" "errors" "fmt" "regexp" "slices" "sort" "strconv" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/route53" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/provider/blueprint" ) const ( defaultAWSProfile = "default" defaultTTL = 300 // From the experiments, it seems that the default MaxItems applied is 100, // and that, on the server side, there is a hard limit of 300 elements per page. // After a discussion with AWS representatives, clients should accept // when fewer items are returned, and still paginate accordingly. // As we are using the standard AWS client, this should already be compliant. // Hence, if AWS ever decides to raise this limit, we will automatically reduce the pressure on rate limits route53PageSize int32 = 300 // providerSpecificAlias specifies whether a CNAME endpoint maps to an AWS ALIAS record. providerSpecificAlias = "alias" providerSpecificTargetHostedZone = "aws/target-hosted-zone" // providerSpecificEvaluateTargetHealth specifies whether an AWS ALIAS record // has the EvaluateTargetHealth field set to true. Present iff the endpoint // has a `providerSpecificAlias` value of `true`. providerSpecificEvaluateTargetHealth = "aws/evaluate-target-health" providerSpecificWeight = "aws/weight" providerSpecificRegion = "aws/region" providerSpecificFailover = "aws/failover" providerSpecificGeolocationContinentCode = "aws/geolocation-continent-code" providerSpecificGeolocationCountryCode = "aws/geolocation-country-code" providerSpecificGeolocationSubdivisionCode = "aws/geolocation-subdivision-code" providerSpecificGeoProximityLocationAWSRegion = "aws/geoproximity-region" providerSpecificGeoProximityLocationBias = "aws/geoproximity-bias" providerSpecificGeoProximityLocationCoordinates = "aws/geoproximity-coordinates" providerSpecificGeoProximityLocationLocalZoneGroup = "aws/geoproximity-local-zone-group" providerSpecificMultiValueAnswer = "aws/multi-value-answer" providerSpecificHealthCheckID = "aws/health-check-id" sameZoneAlias = "same-zone" // Currently supported up to 10 health checks or hosted zones. // https://docs.aws.amazon.com/Route53/latest/APIReference/API_ListTagsForResources.html#API_ListTagsForResources_RequestSyntax batchSize = 10 minLatitude = -90.0 maxLatitude = 90.0 minLongitude = -180.0 maxLongitude = 180.0 ) // see elb: https://docs.aws.amazon.com/general/latest/gr/elb.html var canonicalHostedZones = map[string]string{ // Application Load Balancers and Classic Load Balancers "us-east-2.elb.amazonaws.com": "Z3AADJGX6KTTL2", "us-east-1.elb.amazonaws.com": "Z35SXDOTRQ7X7K", "us-west-1.elb.amazonaws.com": "Z368ELLRRE2KJ0", "us-west-2.elb.amazonaws.com": "Z1H1FL5HABSF5", "ca-central-1.elb.amazonaws.com": "ZQSVJUPU6J1EY", "ca-west-1.elb.amazonaws.com": "Z06473681N0SF6OS049SD", "ap-east-1.elb.amazonaws.com": "Z3DQVH9N71FHZ0", "ap-east-2.elb.amazonaws.com": "Z02789141MW7T1WBU19PO", "ap-south-1.elb.amazonaws.com": "ZP97RAFLXTNZK", "ap-south-2.elb.amazonaws.com": "Z0173938T07WNTVAEPZN", "ap-northeast-2.elb.amazonaws.com": "ZWKZPGTI48KDX", "ap-northeast-3.elb.amazonaws.com": "Z5LXEXXYW11ES", "ap-southeast-1.elb.amazonaws.com": "Z1LMS91P8CMLE5", "ap-southeast-2.elb.amazonaws.com": "Z1GM3OXH4ZPM65", "ap-southeast-3.elb.amazonaws.com": "Z08888821HLRG5A9ZRTER", "ap-southeast-4.elb.amazonaws.com": "Z09517862IB2WZLPXG76F", "ap-southeast-5.elb.amazonaws.com": "Z06010284QMVVW7WO5J", "ap-southeast-6.elb.amazonaws.com": "Z023301818UFJ50CIO0MV", "ap-southeast-7.elb.amazonaws.com": "Z0390008CMBRTHFGWBCB", "ap-northeast-1.elb.amazonaws.com": "Z14GRHDCWA56QT", "eu-central-1.elb.amazonaws.com": "Z215JYRZR1TBD5", "eu-central-2.elb.amazonaws.com": "Z06391101F2ZOEP8P5EB3", "eu-west-1.elb.amazonaws.com": "Z32O12XQLNTSW2", "eu-west-2.elb.amazonaws.com": "ZHURV8PSTC4K8", "eu-west-3.elb.amazonaws.com": "Z3Q77PNBQS71R4", "eu-north-1.elb.amazonaws.com": "Z23TAZ6LKFMNIO", "eu-south-1.elb.amazonaws.com": "Z3ULH7SSC9OV64", "eu-south-2.elb.amazonaws.com": "Z0956581394HF5D5LXGAP", "sa-east-1.elb.amazonaws.com": "Z2P70J7HTTTPLU", "cn-north-1.elb.amazonaws.com.cn": "Z1GDH35T77C1KE", "cn-northwest-1.elb.amazonaws.com.cn": "ZM7IZAIOVVDZF", "us-gov-west-1.elb.amazonaws.com": "Z33AYJ8TM3BH4J", "us-gov-east-1.elb.amazonaws.com": "Z166TLBEWOO7G0", "mx-central-1.elb.amazonaws.com": "Z023552324OKD1BB28BH5", "me-central-1.elb.amazonaws.com": "Z08230872XQRWHG2XF6I", "me-south-1.elb.amazonaws.com": "ZS929ML54UICD", "af-south-1.elb.amazonaws.com": "Z268VQBMOI5EKX", "il-central-1.elb.amazonaws.com": "Z09170902867EHPV2DABU", // Network Load Balancers https://docs.aws.amazon.com/general/latest/gr/elb.html#elb_region "elb.us-east-2.amazonaws.com": "ZLMOA37VPKANP", "elb.us-east-1.amazonaws.com": "Z26RNL4JYFTOTI", "elb.us-west-1.amazonaws.com": "Z24FKFUX50B4VW", "elb.us-west-2.amazonaws.com": "Z18D5FSROUN65G", "elb.ca-central-1.amazonaws.com": "Z2EPGBW3API2WT", "elb.ca-west-1.amazonaws.com": "Z02754302KBB00W2LKWZ9", "elb.ap-east-1.amazonaws.com": "Z12Y7K3UBGUAD1", "elb.ap-east-2.amazonaws.com": "Z09176273OC2HWIAUNYW", "elb.ap-south-1.amazonaws.com": "ZVDDRBQ08TROA", "elb.ap-south-2.amazonaws.com": "Z0711778386UTO08407HT", "elb.ap-northeast-3.amazonaws.com": "Z1GWIQ4HH19I5X", "elb.ap-northeast-2.amazonaws.com": "ZIBE1TIR4HY56", "elb.ap-southeast-1.amazonaws.com": "ZKVM4W9LS7TM", "elb.ap-southeast-2.amazonaws.com": "ZCT6FZBF4DROD", "elb.ap-southeast-3.amazonaws.com": "Z01971771FYVNCOVWJU1G", "elb.ap-southeast-4.amazonaws.com": "Z01156963G8MIIL7X90IV", "elb.ap-southeast-5.amazonaws.com": "Z026317210H9ACVTRO6FB", "elb.ap-southeast-6.amazonaws.com": "Z01392953RKV2Q3RBP0KU", "elb.ap-southeast-7.amazonaws.com": "Z054363131YWATEMWRG5L", "elb.ap-northeast-1.amazonaws.com": "Z31USIVHYNEOWT", "elb.eu-central-1.amazonaws.com": "Z3F0SRJ5LGBH90", "elb.eu-central-2.amazonaws.com": "Z02239872DOALSIDCX66S", "elb.eu-west-1.amazonaws.com": "Z2IFOLAFXWLO4F", "elb.eu-west-2.amazonaws.com": "ZD4D7Y8KGAS4G", "elb.eu-west-3.amazonaws.com": "Z1CMS0P5QUZ6D5", "elb.eu-north-1.amazonaws.com": "Z1UDT6IFJ4EJM", "elb.eu-south-1.amazonaws.com": "Z23146JA1KNAFP", "elb.eu-south-2.amazonaws.com": "Z1011216NVTVYADP1SSV", "elb.sa-east-1.amazonaws.com": "ZTK26PT1VY4CU", "elb.cn-north-1.amazonaws.com.cn": "Z3QFB96KMJ7ED6", "elb.cn-northwest-1.amazonaws.com.cn": "ZQEIKTCZ8352D", "elb.us-gov-west-1.amazonaws.com": "ZMG1MZ2THAWF1", "elb.us-gov-east-1.amazonaws.com": "Z1ZSMQQ6Q24QQ8", "elb.mx-central-1.amazonaws.com": "Z02031231H3ID6HYJ9A7U", "elb.me-central-1.amazonaws.com": "Z00282643NTTLPANJJG2P", "elb.me-south-1.amazonaws.com": "Z3QSRYVP46NYYV", "elb.af-south-1.amazonaws.com": "Z203XCE67M25HM", "elb.il-central-1.amazonaws.com": "Z0313266YDI6ZRHTGQY4", // Global Accelerator "awsglobalaccelerator.com": "Z2BJ6XQ5FK7U4H", // Cloudfront and AWS API Gateway edge-optimized endpoints "cloudfront.net": "Z2FDTNDATAQYW2", // VPC Endpoint (PrivateLink) https://github.com/kubernetes-sigs/external-dns/issues/3429#issuecomment-1440415806 "eu-west-2.vpce.amazonaws.com": "Z7K1066E3PUKB", "us-east-2.vpce.amazonaws.com": "ZC8PG0KIFKBRI", "af-south-1.vpce.amazonaws.com": "Z09302161J80N9A7UTP7U", "ap-east-1.vpce.amazonaws.com": "Z2LIHJ7PKBEMWN", "ap-east-2.vpce.amazonaws.com": "Z09379811HWP0POAUWVN3", "ap-northeast-1.vpce.amazonaws.com": "Z2E726K9Y6RL4W", "ap-northeast-2.vpce.amazonaws.com": "Z27UANNT0PRK1T", "ap-northeast-3.vpce.amazonaws.com": "Z376B5OMM2JZL2", "ap-south-1.vpce.amazonaws.com": "Z2KVTB3ZLFM7JR", "ap-south-2.vpce.amazonaws.com": "Z0952991RWSF5AHIQDIY", "ap-southeast-1.vpce.amazonaws.com": "Z18LLCSTV4NVNL", "ap-southeast-2.vpce.amazonaws.com": "ZDK2GCRPAFKGO", "ap-southeast-3.vpce.amazonaws.com": "Z03881013RZ9BYYZO8N5W", "ap-southeast-4.vpce.amazonaws.com": "Z07508191CO1RNBX3X3AU", "ca-central-1.vpce.amazonaws.com": "ZRCXCF510Y6P9", "eu-central-1.vpce.amazonaws.com": "Z273ZU8SZ5RJPC", "eu-central-2.vpce.amazonaws.com": "Z045369019J4FUQ4S272E", "eu-north-1.vpce.amazonaws.com": "Z3OWWK6JFDEDGC", "eu-south-1.vpce.amazonaws.com": "Z2A5FDNRLY7KZG", "eu-south-2.vpce.amazonaws.com": "Z014396544HENR57XQCJ", "eu-west-1.vpce.amazonaws.com": "Z38GZ743OKFT7T", "eu-west-3.vpce.amazonaws.com": "Z1DWHTMFP0WECP", "me-central-1.vpce.amazonaws.com": "Z07122992YCEUCB9A9570", "me-south-1.vpce.amazonaws.com": "Z3B95P3VBGEQGY", "sa-east-1.vpce.amazonaws.com": "Z2LXUWEVLCVZIB", "us-east-1.vpce.amazonaws.com": "Z7HUB22UULQXV", "us-gov-east-1.vpce.amazonaws.com": "Z2MU5TEIGO9WXB", "us-gov-west-1.vpce.amazonaws.com": "Z12529ZODG2B6H", "us-west-1.vpce.amazonaws.com": "Z12I86A8N7VCZO", "us-west-2.vpce.amazonaws.com": "Z1YSA3EXCYUU9Z", // AWS API Gateway (Regional endpoints) // See: https://docs.aws.amazon.com/general/latest/gr/apigateway.html "execute-api.us-east-2.amazonaws.com": "ZOJJZC49E0EPZ", "execute-api.us-east-1.amazonaws.com": "Z1UJRXOUMOOFQ8", "execute-api.us-west-1.amazonaws.com": "Z2MUQ32089INYE", "execute-api.us-west-2.amazonaws.com": "Z2OJLYMUO9EFXC", "execute-api.af-south-1.amazonaws.com": "Z2DHW2332DAMTN", "execute-api.ap-east-1.amazonaws.com": "Z3FD1VL90ND7K5", "execute-api.ap-east-2.amazonaws.com": "Z02909591O7FG9Q56HWB1", "execute-api.ap-south-1.amazonaws.com": "Z3VO1THU9YC4UR", "execute-api.ap-northeast-2.amazonaws.com": "Z20JF4UZKIW1U8", "execute-api.ap-southeast-1.amazonaws.com": "ZL327KTPIQFUL", "execute-api.ap-southeast-2.amazonaws.com": "Z2RPCDW04V8134", "execute-api.ap-northeast-1.amazonaws.com": "Z1YSHQZHG15GKL", "execute-api.ca-central-1.amazonaws.com": "Z19DQILCV0OWEC", "execute-api.eu-central-1.amazonaws.com": "Z1U9ULNL0V5AJ3", "execute-api.eu-west-1.amazonaws.com": "ZLY8HYME6SFDD", "execute-api.eu-west-2.amazonaws.com": "ZJ5UAJN8Y3Z2Q", "execute-api.eu-south-1.amazonaws.com": "Z3BT4WSQ9TDYZV", "execute-api.eu-west-3.amazonaws.com": "Z3KY65QIEKYHQQ", "execute-api.eu-south-2.amazonaws.com": "Z02499852UI5HEQ5JVWX3", "execute-api.eu-north-1.amazonaws.com": "Z3UWIKFBOOGXPP", "execute-api.me-south-1.amazonaws.com": "Z20ZBPC0SS8806", "execute-api.me-central-1.amazonaws.com": "Z08780021BKYYY8U0YHTV", "execute-api.sa-east-1.amazonaws.com": "ZCMLWB8V5SYIT", "execute-api.us-gov-east-1.amazonaws.com": "Z3SE9ATJYCRCZJ", "execute-api.us-gov-west-1.amazonaws.com": "Z1K6XKP9SAGWDV", } // Route53API is the subset of the AWS Route53 API that we actually use. Add methods as required. Signatures must match exactly. // https://github.com/aws/aws-sdk-go-v2/tree/main/service/route53 type Route53API interface { ListResourceRecordSets(ctx context.Context, input *route53.ListResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) ChangeResourceRecordSets(ctx context.Context, input *route53.ChangeResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) CreateHostedZone(ctx context.Context, input *route53.CreateHostedZoneInput, optFns ...func(*route53.Options)) (*route53.CreateHostedZoneOutput, error) ListHostedZones(ctx context.Context, input *route53.ListHostedZonesInput, optFns ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) ListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) } // Route53Change wrapper to handle ownership relation throughout the provider implementation type Route53Change struct { route53types.Change OwnedRecord string sizeBytes int sizeValues int } type Route53Changes []*Route53Change type profiledZone struct { profile string zone *route53types.HostedZone } type geoProximity struct { location *route53types.GeoProximityLocation endpoint *endpoint.Endpoint isSet bool } func (cs Route53Changes) Route53Changes() []route53types.Change { var ret []route53types.Change for _, c := range cs { ret = append(ret, c.Change) } return ret } type zoneTags map[string]map[string]string // filterZonesByTags filters the provided zones map by matching the tags against the provider's zoneTagFilter. // It removes any zones from the map that do not match the filter criteria. func (z zoneTags) filterZonesByTags(p *AWSProvider, zones map[string]*profiledZone) { for zone, tags := range z { if !p.zoneTagFilter.Match(tags) { delete(zones, zone) } } } // append adds tags to the ZoneTags for a given zoneID. func (z zoneTags) append(id string, tags []route53types.Tag) { zoneId := fmt.Sprintf("/hostedzone/%s", id) if _, ok := z[zoneId]; !ok { z[zoneId] = make(map[string]string) } for _, tag := range tags { z[zoneId][*tag.Key] = *tag.Value } } // AWSProvider is an implementation of Provider for AWS Route53. type AWSProvider struct { provider.BaseProvider clients map[string]Route53API dryRun bool batchChangeSize int batchChangeSizeBytes int batchChangeSizeValues int batchChangeInterval time.Duration evaluateTargetHealth bool // only consider hosted zones managing domains ending in this suffix domainFilter *endpoint.DomainFilter // filter hosted zones by id zoneIDFilter provider.ZoneIDFilter // filter hosted zones by type (e.g. private or public) zoneTypeFilter provider.ZoneTypeFilter // filter hosted zones by tags zoneTagFilter provider.ZoneTagFilter // extend filter for subdomains in the zone (e.g. first.us-east-1.example.com) zoneMatchParent bool preferCNAME bool zonesCache *blueprint.ZoneCache[map[string]*profiledZone] // queue for collecting changes to submit them in the next iteration, but after all other changes failedChangesQueue map[string]Route53Changes } // AWSConfig contains configuration to create a new AWS provider. type AWSConfig struct { DomainFilter *endpoint.DomainFilter ZoneIDFilter provider.ZoneIDFilter ZoneTypeFilter provider.ZoneTypeFilter ZoneTagFilter provider.ZoneTagFilter ZoneMatchParent bool BatchChangeSize int BatchChangeSizeBytes int BatchChangeSizeValues int BatchChangeInterval time.Duration EvaluateTargetHealth bool PreferCNAME bool DryRun bool ZoneCacheDuration time.Duration } // New creates an AWS Route53 provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { configs := CreateV2Configs(cfg) clients := make(map[string]Route53API, len(configs)) for profile, config := range configs { clients[profile] = route53.NewFromConfig(config) } return newProvider( AWSConfig{ DomainFilter: domainFilter, ZoneIDFilter: provider.NewZoneIDFilter(cfg.ZoneIDFilter), ZoneTypeFilter: provider.NewZoneTypeFilter(cfg.AWSZoneType), ZoneTagFilter: provider.NewZoneTagFilter(cfg.AWSZoneTagFilter), ZoneMatchParent: cfg.AWSZoneMatchParent, BatchChangeSize: cfg.AWSBatchChangeSize, BatchChangeSizeBytes: cfg.AWSBatchChangeSizeBytes, BatchChangeSizeValues: cfg.AWSBatchChangeSizeValues, BatchChangeInterval: cfg.AWSBatchChangeInterval, EvaluateTargetHealth: cfg.AWSEvaluateTargetHealth, PreferCNAME: cfg.AWSPreferCNAME, DryRun: cfg.DryRun, ZoneCacheDuration: cfg.AWSZoneCacheDuration, }, clients, ), nil } // newProvider initializes a new AWS Route53 based Provider. func newProvider(cfg AWSConfig, clients map[string]Route53API) *AWSProvider { pr := &AWSProvider{ clients: clients, domainFilter: cfg.DomainFilter, zoneIDFilter: cfg.ZoneIDFilter, zoneTypeFilter: cfg.ZoneTypeFilter, zoneTagFilter: cfg.ZoneTagFilter, zoneMatchParent: cfg.ZoneMatchParent, batchChangeSize: cfg.BatchChangeSize, batchChangeSizeBytes: cfg.BatchChangeSizeBytes, batchChangeSizeValues: cfg.BatchChangeSizeValues, batchChangeInterval: cfg.BatchChangeInterval, evaluateTargetHealth: cfg.EvaluateTargetHealth, preferCNAME: cfg.PreferCNAME, dryRun: cfg.DryRun, zonesCache: blueprint.NewZoneCache[map[string]*profiledZone](cfg.ZoneCacheDuration), failedChangesQueue: make(map[string]Route53Changes), } return pr } // Zones returns the list of hosted zones. func (p *AWSProvider) Zones(ctx context.Context) (map[string]*route53types.HostedZone, error) { zones, err := p.zones(ctx) if err != nil { return nil, err } result := make(map[string]*route53types.HostedZone, len(zones)) for id, zone := range zones { result[id] = zone.zone } return result, nil } // zones returns the list of zones per AWS profile func (p *AWSProvider) zones(ctx context.Context) (map[string]*profiledZone, error) { if !p.zonesCache.Expired() { cachedZones := p.zonesCache.Get() log.Debugf("Using cached AWS zones, zone count: %d.", len(cachedZones)) return cachedZones, nil } log.Debug("Retrieving AWS zones.") zones := make(map[string]*profiledZone) for profile, client := range p.clients { paginator := route53.NewListHostedZonesPaginator(client, &route53.ListHostedZonesInput{}) for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) if err != nil { var te *route53types.ThrottlingException if errors.As(err, &te) { log.Infof("Skipping AWS profile %q due to provider side throttling: %v", profile, te.ErrorMessage()) continue } // nothing to do here. Falling through to general error handling return nil, provider.NewSoftErrorf("failed to list hosted zones: %w", err) } var zonesToTagFilter []string for _, zone := range resp.HostedZones { if !p.zoneIDFilter.Match(*zone.Id) { continue } if !p.zoneTypeFilter.Match(zone) { continue } if !p.domainFilter.Match(*zone.Name) { if !p.zoneMatchParent { continue } if !p.domainFilter.MatchParent(*zone.Name) { continue } } if !p.zoneTagFilter.IsEmpty() { zonesToTagFilter = append(zonesToTagFilter, cleanZoneID(*zone.Id)) } zones[*zone.Id] = &profiledZone{ profile: profile, zone: &zone, } } if len(zonesToTagFilter) > 0 { if zTags, err := p.tagsForZone(ctx, zonesToTagFilter, profile); err != nil { return nil, provider.NewSoftErrorf("failed to list tags for zones %w", err) } else { zTags.filterZonesByTags(p, zones) } } } } if log.IsLevelEnabled(log.DebugLevel) { for _, zone := range zones { log.Debugf("Considering zone: %s (domain: %s)", *zone.zone.Id, *zone.zone.Name) } } p.zonesCache.Reset(zones) return zones, nil } // wildcardUnescape converts \\052.abc back to *.abc // Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk func wildcardUnescape(s string) string { return strings.Replace(s, "\\052", "*", 1) } // See https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html // convertOctalToAscii decodes inputs that contain octal escape sequences into their original ASCII characters. // The function returns converted string where any octal escape sequences have been replaced with their corresponding ASCII characters. func convertOctalToAscii(input string) string { if !containsOctalSequence(input) { return input } result, err := strconv.Unquote("\"" + input + "\"") if err != nil { return input } return result } // validateDomainName checks if the domain name contains valid octal escape sequences. func containsOctalSequence(domain string) bool { // Pattern to match valid octal escape sequences octalEscapePattern := `\\[0-3][0-7]{2}` octalEscapeRegex := regexp.MustCompile(octalEscapePattern) return octalEscapeRegex.MatchString(domain) } // Records returns the list of records in a given hosted zone. func (p *AWSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.zones(ctx) if err != nil { return nil, provider.NewSoftErrorf("records retrieval failed: %v", err) } return p.records(ctx, zones) } func (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZone) ([]*endpoint.Endpoint, error) { endpoints := make([]*endpoint.Endpoint, 0) for _, z := range zones { client := p.clients[z.profile] paginator := route53.NewListResourceRecordSetsPaginator(client, &route53.ListResourceRecordSetsInput{ HostedZoneId: z.zone.Id, MaxItems: aws.Int32(route53PageSize), }) for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) if err != nil { return nil, provider.NewSoftErrorf("failed to list resource records sets for zone %s using aws profile %q: %w", *z.zone.Id, z.profile, err) } for _, r := range resp.ResourceRecordSets { newEndpoints := make([]*endpoint.Endpoint, 0) if !p.SupportedRecordType(r.Type) { continue } name := convertOctalToAscii(wildcardUnescape(*r.Name)) var ttl endpoint.TTL if r.TTL != nil { ttl = endpoint.TTL(*r.TTL) } if len(r.ResourceRecords) > 0 { targets := make([]string, len(r.ResourceRecords)) for idx, rr := range r.ResourceRecords { targets[idx] = *rr.Value } ep := endpoint.NewEndpointWithTTL(name, string(r.Type), ttl, targets...) if r.Type == endpoint.RecordTypeCNAME { ep = ep.WithProviderSpecific(providerSpecificAlias, "false") } newEndpoints = append(newEndpoints, ep) } if r.AliasTarget != nil { // Alias records don't have TTLs so provide the default to match the TXT generation if ttl == 0 { ttl = defaultTTL } ep := endpoint. NewEndpointWithTTL(name, string(r.Type), ttl, *r.AliasTarget.DNSName). WithProviderSpecific(providerSpecificEvaluateTargetHealth, fmt.Sprintf("%t", r.AliasTarget.EvaluateTargetHealth)). WithProviderSpecific(providerSpecificAlias, "true") newEndpoints = append(newEndpoints, ep) } for _, ep := range newEndpoints { if r.SetIdentifier != nil { ep.SetIdentifier = *r.SetIdentifier switch { case r.Weight != nil: ep.WithProviderSpecific(providerSpecificWeight, fmt.Sprintf("%d", *r.Weight)) case r.Region != "": ep.WithProviderSpecific(providerSpecificRegion, string(r.Region)) case r.Failover != "": ep.WithProviderSpecific(providerSpecificFailover, string(r.Failover)) case r.MultiValueAnswer != nil && *r.MultiValueAnswer: ep.WithProviderSpecific(providerSpecificMultiValueAnswer, "") case r.GeoLocation != nil: if r.GeoLocation.ContinentCode != nil { ep.WithProviderSpecific(providerSpecificGeolocationContinentCode, *r.GeoLocation.ContinentCode) } else { if r.GeoLocation.CountryCode != nil { ep.WithProviderSpecific(providerSpecificGeolocationCountryCode, *r.GeoLocation.CountryCode) } if r.GeoLocation.SubdivisionCode != nil { ep.WithProviderSpecific(providerSpecificGeolocationSubdivisionCode, *r.GeoLocation.SubdivisionCode) } } case r.GeoProximityLocation != nil: handleGeoProximityLocationRecord(&r, ep) default: // one of the above needs to be set, otherwise SetIdentifier doesn't make sense } } if r.HealthCheckId != nil { ep.WithProviderSpecific(providerSpecificHealthCheckID, *r.HealthCheckId) } endpoints = append(endpoints, ep) } } } } return endpoints, nil } func handleGeoProximityLocationRecord(r *route53types.ResourceRecordSet, ep *endpoint.Endpoint) { if region := aws.ToString(r.GeoProximityLocation.AWSRegion); region != "" { ep.WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, region) } if bias := r.GeoProximityLocation.Bias; bias != nil { ep.WithProviderSpecific(providerSpecificGeoProximityLocationBias, fmt.Sprintf("%d", aws.ToInt32(bias))) } if coords := r.GeoProximityLocation.Coordinates; coords != nil { coordinates := fmt.Sprintf("%s,%s", aws.ToString(coords.Latitude), aws.ToString(coords.Longitude)) ep.WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, coordinates) } if localZoneGroup := aws.ToString(r.GeoProximityLocation.LocalZoneGroup); localZoneGroup != "" { ep.WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, localZoneGroup) } } // Identify if old and new endpoints require DELETE/CREATE instead of UPDATE. func (p *AWSProvider) requiresDeleteCreate(old *endpoint.Endpoint, newE *endpoint.Endpoint) bool { // a change of a record type if old.RecordType != newE.RecordType { return true } // an ALIAS record change to/from an A if old.RecordType == endpoint.RecordTypeA { oldAlias, _ := old.GetProviderSpecificProperty(providerSpecificAlias) newAlias, _ := newE.GetProviderSpecificProperty(providerSpecificAlias) if oldAlias != newAlias { return true } } // a set identifier change if old.SetIdentifier != newE.SetIdentifier { return true } // a change of routing policy // defaults to true for geolocation properties if any geolocation property exists in old/new but not the other for _, propType := range [7]string{providerSpecificWeight, providerSpecificRegion, providerSpecificFailover, providerSpecificFailover, providerSpecificGeolocationContinentCode, providerSpecificGeolocationCountryCode, providerSpecificGeolocationSubdivisionCode} { _, oldPolicy := old.GetProviderSpecificProperty(propType) _, newPolicy := newE.GetProviderSpecificProperty(propType) if oldPolicy != newPolicy { return true } } return false } func (p *AWSProvider) createUpdateChanges(newEndpoints, oldEndpoints []*endpoint.Endpoint) Route53Changes { var deletes []*endpoint.Endpoint var creates []*endpoint.Endpoint var updates []*endpoint.Endpoint for i, newE := range newEndpoints { if i >= len(oldEndpoints) || oldEndpoints[i] == nil { log.Debugf("skip %s as endpoint not found in current endpoints", newE.DNSName) continue } oldE := oldEndpoints[i] if p.requiresDeleteCreate(oldE, newE) { deletes = append(deletes, oldE) creates = append(creates, newE) } else { // Safe to perform an UPSERT. updates = append(updates, newE) } } combined := make(Route53Changes, 0, len(deletes)+len(creates)+len(updates)) combined = append(combined, p.newChanges(route53types.ChangeActionCreate, creates)...) combined = append(combined, p.newChanges(route53types.ChangeActionUpsert, updates)...) combined = append(combined, p.newChanges(route53types.ChangeActionDelete, deletes)...) return combined } // GetDomainFilter generates a filter to exclude any domain that is not controlled by the provider func (p *AWSProvider) GetDomainFilter() endpoint.DomainFilterInterface { zones, err := p.Zones(context.Background()) if err != nil { log.Errorf("failed to list zones: %v", err) return &endpoint.DomainFilter{} } zoneNames := []string(nil) for _, z := range zones { zoneNames = append(zoneNames, *z.Name, "."+*z.Name) } log.Infof("Applying provider record filter for domains: %v", zoneNames) return endpoint.NewDomainFilter(zoneNames) } // ApplyChanges applies a given set of changes in a given zone. func (p *AWSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { zones, err := p.zones(ctx) if err != nil { return provider.NewSoftErrorf("failed to list zones, not applying changes: %w", err) } updateChanges := p.createUpdateChanges(changes.UpdateNew, changes.UpdateOld) combinedChanges := make(Route53Changes, 0, len(changes.Delete)+len(changes.Create)+len(updateChanges)) combinedChanges = append(combinedChanges, p.newChanges(route53types.ChangeActionCreate, changes.Create)...) combinedChanges = append(combinedChanges, p.newChanges(route53types.ChangeActionDelete, changes.Delete)...) combinedChanges = append(combinedChanges, updateChanges...) return p.submitChanges(ctx, combinedChanges, zones) } // submitChanges takes a zone and a collection of Changes and sends them as a single transaction. func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes, zones map[string]*profiledZone) error { // return early if there is nothing to change if len(changes) == 0 { log.Info("All records are already up to date") return nil } // separate into per-zone change sets to be passed to the API. changesByZone := changesByZone(zones, changes) if len(changesByZone) == 0 { log.Info("All records are already up to date, there are no changes for the matching hosted zones") } var failedZones []string debugLevel := log.DebugLevel for z, cs := range changesByZone { log := log.WithFields(log.Fields{ "zoneName": *zones[z].zone.Name, "zoneID": z, "profile": zones[z].profile, }) var failedUpdate bool // group changes into new changes and into changes that failed in a previous iteration and are retried retriedChanges, newChanges := findChangesInQueue(cs, p.failedChangesQueue[z]) p.failedChangesQueue[z] = nil batchCs := append(batchChangeSet(newChanges, p.batchChangeSize, p.batchChangeSizeBytes, p.batchChangeSizeValues), batchChangeSet(retriedChanges, p.batchChangeSize, p.batchChangeSizeBytes, p.batchChangeSizeValues)...) for i, b := range batchCs { if len(b) == 0 { continue } for _, c := range b { log.Infof("Desired change: %s %s %s", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type) } if p.dryRun { log.Debug("Dry run mode, skipping change submission") continue } params := &route53.ChangeResourceRecordSetsInput{ HostedZoneId: aws.String(z), ChangeBatch: &route53types.ChangeBatch{ Changes: b.Route53Changes(), }, } successfulChanges := 0 client := p.clients[zones[z].profile] if _, err := client.ChangeResourceRecordSets(ctx, params); err != nil { log.Errorf("Failure in zone %s when submitting change batch: %v", *zones[z].zone.Name, err) changesByOwnership := groupChangesByNameAndOwnershipRelation(b) if len(changesByOwnership) > 1 { log.Debug("Trying to submit change sets one-by-one instead") for _, changes := range changesByOwnership { if log.Logger.IsLevelEnabled(debugLevel) { for _, c := range changes { log.Debugf("Desired change: %s %s %s", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type) } } params.ChangeBatch = &route53types.ChangeBatch{ Changes: changes.Route53Changes(), } if _, err := client.ChangeResourceRecordSets(ctx, params); err != nil { failedUpdate = true log.Errorf("Failed submitting change (error: %v), it will be retried in a separate change batch in the next iteration", err) p.failedChangesQueue[z] = append(p.failedChangesQueue[z], changes...) } else { successfulChanges += len(changes) } } } else { failedUpdate = true } } else { successfulChanges = len(b) } if successfulChanges > 0 { // z is the R53 Hosted Zone ID already as aws.StringValue log.Infof("%d record(s) were successfully updated", successfulChanges) } if i != len(batchCs)-1 { time.Sleep(p.batchChangeInterval) } } if failedUpdate { failedZones = append(failedZones, z) } } if len(failedZones) > 0 { return provider.NewSoftErrorf("failed to submit all changes for the following zones: %v", failedZones) } return nil } // newChanges returns a collection of Changes based on the given records and action. func (p *AWSProvider) newChanges(action route53types.ChangeAction, endpoints []*endpoint.Endpoint) Route53Changes { changes := make(Route53Changes, 0, len(endpoints)) for _, ep := range endpoints { change := p.newChange(action, ep) changes = append(changes, change) } return changes } // AdjustEndpoints modifies the provided endpoints (coming from various sources) to match // the endpoints that the provider returns in `Records` so that the change plan will not have // unneeded (potentially failing) changes. // Example: CNAME endpoints pointing to ELBs will have a `alias` provider-specific property // added to match the endpoints generated from existing alias records in Route53. func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { // Holds CNAME targets that we will treat as Alias records. Such records are // hard coded to 'A' type aliases but we also need their 'AAAA' counterparts. var aliasCnameAaaaEndpoints []*endpoint.Endpoint for _, ep := range endpoints { if aaaa := p.adjustEndpointAndNewAaaaIfNeeded(ep); aaaa != nil { aliasCnameAaaaEndpoints = append(aliasCnameAaaaEndpoints, aaaa) } } return append(endpoints, aliasCnameAaaaEndpoints...), nil } func (p *AWSProvider) adjustEndpointAndNewAaaaIfNeeded(ep *endpoint.Endpoint) *endpoint.Endpoint { var aaaa *endpoint.Endpoint switch ep.RecordType { case endpoint.RecordTypeA, endpoint.RecordTypeAAAA: p.adjustAandAAAARecord(ep) case endpoint.RecordTypeCNAME: p.adjustCNAMERecord(ep) adjustGeoProximityLocationEndpoint(ep) if isAlias, _ := ep.GetBoolProviderSpecificProperty(providerSpecificAlias); isAlias { aaaa = ep.DeepCopy() aaaa.RecordType = endpoint.RecordTypeAAAA } return aaaa default: p.adjustOtherRecord(ep) } adjustGeoProximityLocationEndpoint(ep) return aaaa } func (p *AWSProvider) adjustAliasRecord(ep *endpoint.Endpoint) { if ep.RecordTTL.IsConfigured() { log.Debugf("Modifying endpoint: %v, setting ttl=%v", ep, defaultTTL) ep.RecordTTL = defaultTTL } if enable, exists := ep.GetBoolProviderSpecificProperty(providerSpecificEvaluateTargetHealth); exists { // normalize to string "true"/"false" ep.SetProviderSpecificProperty(providerSpecificEvaluateTargetHealth, strconv.FormatBool(enable)) } else { // if not set, use provider default ep.SetProviderSpecificProperty(providerSpecificEvaluateTargetHealth, strconv.FormatBool(p.evaluateTargetHealth)) } } func (p *AWSProvider) adjustAandAAAARecord(ep *endpoint.Endpoint) { isAlias, _ := ep.GetBoolProviderSpecificProperty(providerSpecificAlias) if isAlias { p.adjustAliasRecord(ep) } else { ep.DeleteProviderSpecificProperty(providerSpecificAlias) ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth) } } func (p *AWSProvider) adjustCNAMERecord(ep *endpoint.Endpoint) { isAlias, exists := ep.GetBoolProviderSpecificProperty(providerSpecificAlias) // fallback to determining alias based on preferCNAME if not explicitly set if !exists { isAlias = useAlias(ep, p.preferCNAME) log.Debugf("Modifying endpoint: %v, setting %s=%v", ep, providerSpecificAlias, isAlias) ep.SetProviderSpecificProperty(providerSpecificAlias, strconv.FormatBool(isAlias)) } // if not an alias, ensure alias properties are adjusted accordingly if !isAlias { if exists { // normalize to string "false" when provider specific alias is set to false or other non-true value ep.SetProviderSpecificProperty(providerSpecificAlias, "false") } ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth) } // if an alias, convert to A record and adjust alias properties if isAlias { ep.RecordType = endpoint.RecordTypeA p.adjustAliasRecord(ep) } } func (p *AWSProvider) adjustOtherRecord(ep *endpoint.Endpoint) { // TODO: fix For records other than A, AAAA, and CNAME, if an alias record is set, the alias record processing is not performed. // This will be fixed in another PR. if isAlias, _ := ep.GetBoolProviderSpecificProperty(providerSpecificAlias); isAlias { p.adjustAliasRecord(ep) ep.DeleteProviderSpecificProperty(providerSpecificAlias) } else { ep.DeleteProviderSpecificProperty(providerSpecificAlias) ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth) } } // if the endpoint is using geoproximity, set the bias to 0 if not set // this is needed to avoid unnecessary Upserts if the desired endpoint doesn't specify a bias func adjustGeoProximityLocationEndpoint(ep *endpoint.Endpoint) { if ep.SetIdentifier == "" { return } _, ok1 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion) _, ok2 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup) _, ok3 := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates) if ok1 || ok2 || ok3 { // check if ep has bias property and if not, set it to 0 if _, ok := ep.GetProviderSpecificProperty(providerSpecificGeoProximityLocationBias); !ok { ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationBias, "0") } } } // newChange returns a route53 Change // returned Change is based on the given record by the given action, e.g. // action=ChangeActionCreate returns a change for creation of the record and // action=ChangeActionDelete returns a change for deletion of the record. func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.Endpoint) *Route53Change { change := &Route53Change{ Change: route53types.Change{ Action: action, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(ep.DNSName), }, }, } change.ResourceRecordSet.Type = route53types.RRType(ep.RecordType) if targetHostedZone := isAWSAlias(ep); targetHostedZone != "" { evalTargetHealth := p.evaluateTargetHealth if prop, exists := ep.GetBoolProviderSpecificProperty(providerSpecificEvaluateTargetHealth); exists { evalTargetHealth = prop } change.ResourceRecordSet.AliasTarget = &route53types.AliasTarget{ DNSName: aws.String(ep.Targets[0]), HostedZoneId: aws.String(cleanZoneID(targetHostedZone)), EvaluateTargetHealth: evalTargetHealth, } change.sizeBytes += len([]byte(ep.Targets[0])) change.sizeValues += 1 } else { if !ep.RecordTTL.IsConfigured() { change.ResourceRecordSet.TTL = aws.Int64(defaultTTL) } else { change.ResourceRecordSet.TTL = aws.Int64(int64(ep.RecordTTL)) } change.ResourceRecordSet.ResourceRecords = make([]route53types.ResourceRecord, len(ep.Targets)) for idx, val := range ep.Targets { change.ResourceRecordSet.ResourceRecords[idx] = route53types.ResourceRecord{ Value: aws.String(val), } change.sizeBytes += len([]byte(val)) change.sizeValues += 1 } } if action == route53types.ChangeActionUpsert { // If the value of the Action element is UPSERT, each ResourceRecord element and each character in a Value // element is counted twice change.sizeBytes *= 2 change.sizeValues *= 2 } if ep.SetIdentifier != "" { change.ResourceRecordSet.SetIdentifier = aws.String(ep.SetIdentifier) } if prop, ok := ep.GetProviderSpecificProperty(providerSpecificWeight); ok { weight, err := strconv.ParseInt(prop, 10, 64) if err != nil { log.Errorf("Failed parsing value of %s: %s: %v; using weight of 0", providerSpecificWeight, prop, err) weight = 0 } change.ResourceRecordSet.Weight = aws.Int64(weight) } if prop, ok := ep.GetProviderSpecificProperty(providerSpecificRegion); ok { change.ResourceRecordSet.Region = route53types.ResourceRecordSetRegion(prop) } if prop, ok := ep.GetProviderSpecificProperty(providerSpecificFailover); ok { change.ResourceRecordSet.Failover = route53types.ResourceRecordSetFailover(prop) } if _, ok := ep.GetProviderSpecificProperty(providerSpecificMultiValueAnswer); ok { change.ResourceRecordSet.MultiValueAnswer = aws.Bool(true) } geolocation := &route53types.GeoLocation{} useGeolocation := false if prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationContinentCode); ok { geolocation.ContinentCode = aws.String(prop) useGeolocation = true } else { if prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationCountryCode); ok { geolocation.CountryCode = aws.String(prop) useGeolocation = true } if prop, ok := ep.GetProviderSpecificProperty(providerSpecificGeolocationSubdivisionCode); ok { geolocation.SubdivisionCode = aws.String(prop) useGeolocation = true } } if useGeolocation { change.ResourceRecordSet.GeoLocation = geolocation } withChangeForGeoProximityEndpoint(change, ep) if prop, ok := ep.GetProviderSpecificProperty(providerSpecificHealthCheckID); ok { change.ResourceRecordSet.HealthCheckId = aws.String(prop) } if ownedRecord, ok := ep.Labels[endpoint.OwnedRecordLabelKey]; ok { change.OwnedRecord = ownedRecord } return change } func newGeoProximity(ep *endpoint.Endpoint) *geoProximity { return &geoProximity{ location: &route53types.GeoProximityLocation{}, endpoint: ep, isSet: false, } } func (gp *geoProximity) withAWSRegion() *geoProximity { if prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion); ok { gp.location.AWSRegion = aws.String(prop) gp.isSet = true } return gp } // add a method to set the local zone group for the geoproximity location func (gp *geoProximity) withLocalZoneGroup() *geoProximity { if prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup); ok { gp.location.LocalZoneGroup = aws.String(prop) gp.isSet = true } return gp } // add a method to set the bias for the geoproximity location func (gp *geoProximity) withBias() *geoProximity { if prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationBias); ok { bias, err := strconv.ParseInt(prop, 10, 32) if err != nil { log.Warnf("Failed parsing value of %s: %s: %v; using bias of 0", providerSpecificGeoProximityLocationBias, prop, err) bias = 0 } gp.location.Bias = aws.Int32(int32(bias)) gp.isSet = true } return gp } // validateCoordinates checks if the given latitude and longitude are valid. func validateCoordinates(lat, long string) error { latitude, err := strconv.ParseFloat(lat, 64) if err != nil || latitude < minLatitude || latitude > maxLatitude { return fmt.Errorf("invalid latitude: must be a number between %f and %f", minLatitude, maxLatitude) } longitude, err := strconv.ParseFloat(long, 64) if err != nil || longitude < minLongitude || longitude > maxLongitude { return fmt.Errorf("invalid longitude: must be a number between %f and %f", minLongitude, maxLongitude) } return nil } func (gp *geoProximity) withCoordinates() *geoProximity { if prop, ok := gp.endpoint.GetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates); ok { coordinates := strings.Split(prop, ",") if len(coordinates) == 2 { latitude := coordinates[0] longitude := coordinates[1] if err := validateCoordinates(latitude, longitude); err != nil { log.Warnf("Invalid coordinates %s for name=%s setIdentifier=%s; %v", prop, gp.endpoint.DNSName, gp.endpoint.SetIdentifier, err) } else { gp.location.Coordinates = &route53types.Coordinates{ Latitude: aws.String(latitude), Longitude: aws.String(longitude), } gp.isSet = true } } else { log.Warnf("Invalid coordinates format for %s: %s; expected format 'latitude,longitude'", providerSpecificGeoProximityLocationCoordinates, prop) } } return gp } func (gp *geoProximity) build() *route53types.GeoProximityLocation { if gp.isSet { return gp.location } return nil } func withChangeForGeoProximityEndpoint(change *Route53Change, ep *endpoint.Endpoint) { geoProx := newGeoProximity(ep). withAWSRegion(). withCoordinates(). withLocalZoneGroup(). withBias() change.ResourceRecordSet.GeoProximityLocation = geoProx.build() } // searches for `changes` that are contained in `queue` and returns the `changes` separated by whether they were found in the queue (`foundChanges`) or not (`notFoundChanges`) func findChangesInQueue(changes Route53Changes, queue Route53Changes) (Route53Changes, Route53Changes) { if queue == nil { return Route53Changes{}, changes } var foundChanges, notFoundChanges Route53Changes for _, c := range changes { found := false if slices.Contains(queue, c) { foundChanges = append(foundChanges, c) found = true } if !found { notFoundChanges = append(notFoundChanges, c) } } return foundChanges, notFoundChanges } // group the given changes by name and ownership relation to ensure these are always submitted in the same transaction to Route53; // grouping by name is done to always submit changes with the same name but different set identifier together, // grouping by ownership relation is done to always submit changes of records and e.g. their corresponding TXT registry records together func groupChangesByNameAndOwnershipRelation(cs Route53Changes) map[string]Route53Changes { changesByOwnership := make(map[string]Route53Changes) for _, v := range cs { key := v.OwnedRecord if key == "" { key = *v.ResourceRecordSet.Name } changesByOwnership[key] = append(changesByOwnership[key], v) } return changesByOwnership } func (p *AWSProvider) tagsForZone(ctx context.Context, zoneIDs []string, profile string) (zoneTags, error) { client := p.clients[profile] result := zoneTags{} for i := 0; i < len(zoneIDs); i += batchSize { batch := zoneIDs[i:min(i+batchSize, len(zoneIDs))] if len(batch) == 0 { break } response, err := client.ListTagsForResources(ctx, &route53.ListTagsForResourcesInput{ ResourceType: route53types.TagResourceTypeHostedzone, ResourceIds: batch, }) if err != nil { return nil, provider.NewSoftErrorf("failed to list tags for zones. %v", err) } for _, res := range response.ResourceTagSets { result.append(*res.ResourceId, res.Tags) } } return result, nil } // count bytes for all changes values func countChangeBytes(cs Route53Changes) int { count := 0 for _, c := range cs { count += c.sizeBytes } return count } // count total value count for all changes func countChangeValues(cs Route53Changes) int { count := 0 for _, c := range cs { count += c.sizeValues } return count } func batchChangeSet(cs Route53Changes, batchSize int, batchSizeBytes int, batchSizeValues int) []Route53Changes { if len(cs) <= batchSize && countChangeBytes(cs) <= batchSizeBytes && countChangeValues(cs) <= batchSizeValues { res := sortChangesByActionNameType(cs) return []Route53Changes{res} } batchChanges := make([]Route53Changes, 0) changesByOwnership := groupChangesByNameAndOwnershipRelation(cs) names := make([]string, 0) for v := range changesByOwnership { names = append(names, v) } sort.Strings(names) currentBatch := Route53Changes{} for k, name := range names { v := changesByOwnership[name] vBytes := countChangeBytes(v) vValues := countChangeValues(v) if len(v) > batchSize { log.Warnf("Total changes for %v exceeds max batch size of %d, total changes: %d; changes will not be performed", k, batchSize, len(v)) continue } if vBytes > batchSizeBytes { log.Warnf("Total changes for %v exceeds max batch size bytes of %d, total changes bytes: %d; changes will not be performed", k, batchSizeBytes, vBytes) continue } if vValues > batchSizeValues { log.Warnf("Total changes for %v exceeds max batch size values of %d, total changes values: %d; changes will not be performed", k, batchSizeValues, vValues) continue } bytes := countChangeBytes(currentBatch) + vBytes values := countChangeValues(currentBatch) + vValues if len(currentBatch)+len(v) > batchSize || bytes > batchSizeBytes || values > batchSizeValues { // currentBatch would be too large if we add this changeset; // add currentBatch to batchChanges and start a new currentBatch batchChanges = append(batchChanges, sortChangesByActionNameType(currentBatch)) currentBatch = append(Route53Changes{}, v...) } else { currentBatch = append(currentBatch, v...) } } if len(currentBatch) > 0 { // add final currentBatch batchChanges = append(batchChanges, sortChangesByActionNameType(currentBatch)) } return batchChanges } func sortChangesByActionNameType(cs Route53Changes) Route53Changes { sort.SliceStable(cs, func(i, j int) bool { if cs[i].Action > cs[j].Action { return true } if cs[i].Action < cs[j].Action { return false } if *cs[i].ResourceRecordSet.Name < *cs[j].ResourceRecordSet.Name { return true } if *cs[i].ResourceRecordSet.Name > *cs[j].ResourceRecordSet.Name { return false } return cs[i].ResourceRecordSet.Type < cs[j].ResourceRecordSet.Type }) return cs } // changesByZone separates a multi-zone change into a single change per zone. func changesByZone(zones map[string]*profiledZone, changeSet Route53Changes) map[string]Route53Changes { changes := make(map[string]Route53Changes) for _, z := range zones { changes[*z.zone.Id] = Route53Changes{} } for _, c := range changeSet { hostname := provider.EnsureTrailingDot(*c.ResourceRecordSet.Name) zones := suitableZones(hostname, zones) if len(zones) == 0 { log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", *c.ResourceRecordSet.Name) continue } for _, z := range zones { if c.ResourceRecordSet.AliasTarget != nil && *c.ResourceRecordSet.AliasTarget.HostedZoneId == sameZoneAlias { // alias record is to be created; target needs to be in the same zone as endpoint // if it's not, this will fail rrset := *c.ResourceRecordSet aliasTarget := *rrset.AliasTarget aliasTarget.HostedZoneId = aws.String(cleanZoneID(*z.zone.Id)) rrset.AliasTarget = &aliasTarget c = &Route53Change{ Change: route53types.Change{ Action: c.Action, ResourceRecordSet: &rrset, }, } } changes[*z.zone.Id] = append(changes[*z.zone.Id], c) log.Debugf("Adding %s to zone %s [Id: %s]", hostname, *z.zone.Name, *z.zone.Id) } } // separating a change could lead to empty sub changes, remove them here. for zone, change := range changes { if len(change) == 0 { delete(changes, zone) } } return changes } // suitableZones returns all suitable private zones and the most suitable public zone // // for a given hostname and a set of zones. func suitableZones(hostname string, zones map[string]*profiledZone) []*profiledZone { var matchingZones []*profiledZone var publicZone *profiledZone for _, z := range zones { if *z.zone.Name == hostname || strings.HasSuffix(hostname, "."+*z.zone.Name) { if z.zone.Config == nil || !z.zone.Config.PrivateZone { // Only select the best matching public zone if publicZone == nil || len(*z.zone.Name) > len(*publicZone.zone.Name) { publicZone = z } } else { // Include all private zones matchingZones = append(matchingZones, z) } } } if publicZone != nil { matchingZones = append(matchingZones, publicZone) } return matchingZones } // useAlias determines if AWS ALIAS should be used. func useAlias(ep *endpoint.Endpoint, preferCNAME bool) bool { if preferCNAME { return false } if ep.RecordType == endpoint.RecordTypeCNAME && len(ep.Targets) > 0 { return canonicalHostedZone(ep.Targets[0]) != "" } return false } // isAWSAlias determines if a given endpoint is supposed to create an AWS Alias record // and (if so) returns the target hosted zone ID func isAWSAlias(ep *endpoint.Endpoint) string { isAlias, _ := ep.GetBoolProviderSpecificProperty(providerSpecificAlias) if isAlias && slices.Contains([]string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA}, ep.RecordType) && len(ep.Targets) > 0 { // alias records can only point to canonical hosted zones (e.g. to ELBs) or other records in the same zone if hostedZoneID, ok := ep.GetProviderSpecificProperty(providerSpecificTargetHostedZone); ok { // existing Endpoint where we got the target hosted zone from the Route53 data return hostedZoneID } // check if the target is in a canonical hosted zone if canonicalHostedZone := canonicalHostedZone(ep.Targets[0]); canonicalHostedZone != "" { return canonicalHostedZone } // if not, target needs to be in the same zone return sameZoneAlias } return "" } // canonicalHostedZone returns the matching canonical zone for a given hostname. func canonicalHostedZone(hostname string) string { // strings.HasSuffix is optimized for this specific task and avoids the overhead associated with compiling and executing a regular expression. if strings.HasSuffix(hostname, "aws.com") || strings.HasSuffix(hostname, "aws.com.cn") || strings.HasSuffix(hostname, "tor.com") || strings.HasSuffix(hostname, "ont.com") || strings.HasSuffix(hostname, "ont.net") { parts := strings.Split(hostname, ".") // iterate from the second-last part (zone) towards the beginning for i := len(parts) - 2; i >= 0; i-- { suffix := strings.Join(parts[i:], ".") if zone, exists := canonicalHostedZones[suffix]; exists { return zone } } } if strings.HasSuffix(hostname, ".amazonaws.com") { // hostname is an AWS hostname, but could not find canonical hosted zone. // This could mean that a new region has been added but is not supported yet. log.Warnf("Could not find canonical hosted zone for domain %s. This may be because your region is not supported yet.", hostname) } return "" } // cleanZoneID removes the "/hostedzone/" prefix func cleanZoneID(id string) string { return strings.TrimPrefix(id, "/hostedzone/") } func (p *AWSProvider) SupportedRecordType(recordType route53types.RRType) bool { switch recordType { case route53types.RRTypeMx, route53types.RRTypeNaptr: return true default: return provider.SupportedRecordType(string(recordType)) } } ================================================ FILE: provider/aws/aws_fixtures_test.go ================================================ /* 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 aws import ( "fmt" "strings" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) func TestAWSRecordsV1(t *testing.T) { var zones HostedZones unmarshalZonesFixture(&zones, t) stub := NewRoute53APIFixtureStub(&zones) provider := providerFilters(stub, WithZoneIDFilters( "Z10242883PKPS38KA4S6C", "Z10295763LSQ170JCTR78", "Z102957NOTEXISTS", "Z09418121E8V6WT4FASZE", ), WithDomainFilters("w2.w1.ex.com", "ex.com"), ) ctx := t.Context() z, err := provider.Zones(ctx) assert.NoError(t, err) assert.Len(t, z, 3) } func TestAWSZonesFilterWithTags(t *testing.T) { var zones HostedZones unmarshalZonesFixture(&zones, t) stub := NewRoute53APIFixtureStub(&zones) provider := providerFilters(stub, WithZoneTagFilters([]string{"level=5", "owner=ext-dns"}), ) ctx := t.Context() z, err := provider.Zones(ctx) assert.NoError(t, err) assert.Len(t, z, 24) assert.Equal(t, 17, stub.calls["listtagsforresource"]) } func TestAWSZonesFiltersWithTags(t *testing.T) { tests := []struct { filters []string want, calls int }{ {[]string{"owner=ext-dns"}, 169, 17}, {[]string{"domain=n3.n2.n1.ex.com"}, 1, 17}, {[]string{"parentdomain=n3.n2.n1.ex.com"}, 1, 17}, {[]string{"vpcid=vpc-not-exists"}, 0, 17}, } for _, tt := range tests { tName := fmt.Sprintf("filters=%s and zones=%d", strings.Join(tt.filters, ","), tt.want) t.Run(tName, func(t *testing.T) { var zones HostedZones unmarshalZonesFixture(&zones, t) stub := NewRoute53APIFixtureStub(&zones) provider := providerFilters(stub, WithZoneTagFilters(tt.filters), ) z, err := provider.Zones(t.Context()) assert.NoError(t, err) assert.Len(t, z, tt.want) assert.Equal(t, tt.calls, stub.calls["listtagsforresource"]) }) } } func TestAWSZonesSecondRequestHitsTheCache(t *testing.T) { var zones HostedZones unmarshalZonesFixture(&zones, t) stub := NewRoute53APIFixtureStub(&zones) provider := providerFilters(stub) ctx := t.Context() _, err := provider.Zones(ctx) assert.NoError(t, err) hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) _, _ = provider.Zones(ctx) logtest.TestHelperLogContainsWithLogLevel("Using cached AWS zones", log.DebugLevel, hook, t) } ================================================ FILE: provider/aws/aws_test.go ================================================ /* Copyright 2017 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 aws import ( "bytes" "context" "fmt" "maps" "math" "net" "sort" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/route53" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/provider/blueprint" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( defaultBatchChangeSize = 4000 defaultBatchChangeSizeBytes = 32000 defaultBatchChangeSizeValues = 1000 defaultBatchChangeInterval = time.Second defaultEvaluateTargetHealth = true ) // Compile time check for interface conformance var _ Route53API = &Route53APIStub{} // Route53APIStub is a minimal implementation of Route53API, used primarily for unit testing. // See http://http://docs.aws.amazon.com/sdk-for-go/api/service/route53.html for descriptions // of all of its methods. // mostly taken from: https://github.com/kubernetes/kubernetes/blob/853167624edb6bc0cfdcdfb88e746e178f5db36c/federation/pkg/dnsprovider/providers/aws/route53/stubs/route53api.go type Route53APIStub struct { zones map[string]*route53types.HostedZone recordSets map[string]map[string][]route53types.ResourceRecordSet zoneTags map[string][]route53types.Tag m dynamicMock t *testing.T } // MockMethod starts a description of an expectation of the specified method // being called. // // Route53APIStub.MockMethod("MyMethod", arg1, arg2) func (r *Route53APIStub) MockMethod(method string, args ...any) *mock.Call { return r.m.On(method, args...) } // NewRoute53APIStub returns an initialized Route53APIStub func NewRoute53APIStub(t *testing.T) *Route53APIStub { return &Route53APIStub{ zones: make(map[string]*route53types.HostedZone), recordSets: make(map[string]map[string][]route53types.ResourceRecordSet), zoneTags: make(map[string][]route53types.Tag), t: t, } } func (r *Route53APIStub) ListResourceRecordSets(ctx context.Context, input *route53.ListResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { if r.m.isMocked("ListResourceRecordSets", input) { return r.m.ListResourceRecordSets(ctx, input, optFns...) } output := &route53.ListResourceRecordSetsOutput{} // TODO: Support optional input args. require.NotNil(r.t, input.MaxItems) assert.Equal(r.t, route53PageSize, *input.MaxItems) if len(r.recordSets) == 0 { output.ResourceRecordSets = []route53types.ResourceRecordSet{} } else if _, ok := r.recordSets[*input.HostedZoneId]; !ok { output.ResourceRecordSets = []route53types.ResourceRecordSet{} } else { for _, rrsets := range r.recordSets[*input.HostedZoneId] { output.ResourceRecordSets = append(output.ResourceRecordSets, rrsets...) } } return output, nil } type Route53APICounter struct { wrapped Route53API calls map[string]int } func NewRoute53APICounter(w Route53API) *Route53APICounter { return &Route53APICounter{ wrapped: w, calls: map[string]int{}, } } func (c *Route53APICounter) ListResourceRecordSets(ctx context.Context, input *route53.ListResourceRecordSetsInput, optFns ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { c.calls["ListResourceRecordSetsPages"]++ return c.wrapped.ListResourceRecordSets(ctx, input, optFns...) } func (c *Route53APICounter) ChangeResourceRecordSets(ctx context.Context, input *route53.ChangeResourceRecordSetsInput, optFns ...func(*route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { c.calls["ChangeResourceRecordSets"]++ return c.wrapped.ChangeResourceRecordSets(ctx, input, optFns...) } func (c *Route53APICounter) CreateHostedZone(ctx context.Context, input *route53.CreateHostedZoneInput, optFns ...func(*route53.Options)) (*route53.CreateHostedZoneOutput, error) { c.calls["CreateHostedZone"]++ return c.wrapped.CreateHostedZone(ctx, input, optFns...) } func (c *Route53APICounter) ListHostedZones(ctx context.Context, input *route53.ListHostedZonesInput, optFns ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) { c.calls["ListHostedZonesPages"]++ return c.wrapped.ListHostedZones(ctx, input, optFns...) } func (c *Route53APICounter) ListTagsForResources(ctx context.Context, input *route53.ListTagsForResourcesInput, optFns ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) { c.calls["ListTagsForResource"]++ return c.wrapped.ListTagsForResources(ctx, input, optFns...) } // Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk func wildcardEscape(s string) string { if strings.Contains(s, "*") { s = strings.Replace(s, "*", "\\052", 1) } return s } // Route53 octal escapes https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html func specialCharactersEscape(s string) string { var result strings.Builder for _, char := range s { if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' || char == '.' { result.WriteRune(char) } else { octalCode := fmt.Sprintf("\\%03o", char) result.WriteString(octalCode) } } return result.String() } func (r *Route53APIStub) ListTagsForResources(_ context.Context, input *route53.ListTagsForResourcesInput, _ ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) { if input.ResourceType == route53types.TagResourceTypeHostedzone { var sets []route53types.ResourceTagSet for _, el := range input.ResourceIds { zoneId := fmt.Sprintf("/%s/%s", input.ResourceType, el) if strings.Contains(zoneId, "ext-dns-test-error-on-list-tags") { return nil, fmt.Errorf("operation error Route53APIStub: ListTagsForResource") } if r.zoneTags[zoneId] != nil { sets = append(sets, route53types.ResourceTagSet{ ResourceId: &el, ResourceType: route53types.TagResourceTypeHostedzone, Tags: r.zoneTags[zoneId], }) } } return &route53.ListTagsForResourcesOutput{ResourceTagSets: sets}, nil } return &route53.ListTagsForResourcesOutput{}, nil } func (r *Route53APIStub) ChangeResourceRecordSets(_ context.Context, input *route53.ChangeResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { if r.m.isMocked("ChangeResourceRecordSets", input) { return r.m.ChangeResourceRecordSets(input) } _, ok := r.zones[*input.HostedZoneId] if !ok { return nil, fmt.Errorf("hosted zone doesn't exist: %s", *input.HostedZoneId) } if len(input.ChangeBatch.Changes) == 0 { return nil, fmt.Errorf("ChangeBatch doesn't contain any changes") } output := &route53.ChangeResourceRecordSetsOutput{} recordSets, ok := r.recordSets[*input.HostedZoneId] if !ok { recordSets = make(map[string][]route53types.ResourceRecordSet) } for _, change := range input.ChangeBatch.Changes { if change.ResourceRecordSet.Type == route53types.RRTypeA { for _, rrs := range change.ResourceRecordSet.ResourceRecords { if net.ParseIP(*rrs.Value) == nil { return nil, fmt.Errorf("A records must point to IPs") } } } change.ResourceRecordSet.Name = aws.String(wildcardEscape(provider.EnsureTrailingDot(*change.ResourceRecordSet.Name))) if change.ResourceRecordSet.AliasTarget != nil { change.ResourceRecordSet.AliasTarget.DNSName = aws.String(wildcardEscape(provider.EnsureTrailingDot(*change.ResourceRecordSet.AliasTarget.DNSName))) } setID := "" if change.ResourceRecordSet.SetIdentifier != nil { setID = *change.ResourceRecordSet.SetIdentifier } key := *change.ResourceRecordSet.Name + "::" + string(change.ResourceRecordSet.Type) + "::" + setID switch change.Action { case route53types.ChangeActionCreate: if _, found := recordSets[key]; found { return nil, fmt.Errorf("attempt to create duplicate rrset %s", key) // TODO: Return AWS errors with codes etc } recordSets[key] = append(recordSets[key], *change.ResourceRecordSet) case route53types.ChangeActionDelete: if _, found := recordSets[key]; !found { return nil, fmt.Errorf("attempt to delete non-existent rrset %s", key) // TODO: Check other fields too } delete(recordSets, key) case route53types.ChangeActionUpsert: recordSets[key] = []route53types.ResourceRecordSet{*change.ResourceRecordSet} } } r.recordSets[*input.HostedZoneId] = recordSets return output, nil // TODO: We should ideally return status etc, but we don't' use that yet. } func (r *Route53APIStub) ListHostedZones(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) { output := &route53.ListHostedZonesOutput{} for _, zone := range r.zones { output.HostedZones = append(output.HostedZones, *zone) } return output, nil } func (r *Route53APIStub) CreateHostedZone(_ context.Context, input *route53.CreateHostedZoneInput, _ ...func(options *route53.Options)) (*route53.CreateHostedZoneOutput, error) { name := *input.Name id := "/hostedzone/" + name if _, ok := r.zones[id]; ok { return nil, fmt.Errorf("Error creating hosted DNS zone: %s already exists", id) } r.zones[id] = &route53types.HostedZone{ Id: aws.String(id), Name: aws.String(name), Config: input.HostedZoneConfig, } return &route53.CreateHostedZoneOutput{HostedZone: r.zones[id]}, nil } type dynamicMock struct { mock.Mock } func (m *dynamicMock) ListResourceRecordSets(_ context.Context, input *route53.ListResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { args := m.Called(input) if args.Get(0) != nil { return args.Get(0).(*route53.ListResourceRecordSetsOutput), args.Error(1) } return nil, args.Error(1) } func (m *dynamicMock) ChangeResourceRecordSets(input *route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error) { args := m.Called(input) if args.Get(0) != nil { return args.Get(0).(*route53.ChangeResourceRecordSetsOutput), args.Error(1) } return nil, args.Error(1) } func (m *dynamicMock) isMocked(method string, arguments ...any) bool { for _, call := range m.ExpectedCalls { if call.Method == method && call.Repeatability > -1 { _, diffCount := call.Arguments.Diff(arguments) if diffCount == 0 { return true } } } return false } func TestAWSZones(t *testing.T) { publicZones := map[string]*route53types.HostedZone{ "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.": { Id: aws.String("/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), Name: aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."), }, "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.": { Id: aws.String("/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."), Name: aws.String("zone-2.ext-dns-test-2.teapot.zalan.do."), }, } privateZones := map[string]*route53types.HostedZone{ "/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.": { Id: aws.String("/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."), Name: aws.String("zone-3.ext-dns-test-2.teapot.zalan.do."), }, } allZones := map[string]*route53types.HostedZone{} maps.Copy(allZones, publicZones) maps.Copy(allZones, privateZones) noZones := map[string]*route53types.HostedZone{} for _, ti := range []struct { msg string zoneIDFilter provider.ZoneIDFilter zoneTypeFilter provider.ZoneTypeFilter zoneTagFilter provider.ZoneTagFilter expectedZones map[string]*route53types.HostedZone }{ {"no filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{}), allZones}, {"public filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("public"), provider.NewZoneTagFilter([]string{}), publicZones}, {"private filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("private"), provider.NewZoneTagFilter([]string{}), privateZones}, {"unknown filter", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter("unknown"), provider.NewZoneTagFilter([]string{}), noZones}, {"zone id filter", provider.NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{}), privateZones}, {"tag filter zero zone match", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{"zone=not-exists"}), noZones}, {"tag filter single zone match", provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), provider.NewZoneTagFilter([]string{"zone=3"}), privateZones}, } { t.Run(ti.msg, func(t *testing.T) { provider, _ := newAWSProviderWithTagFilter(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, ti.zoneTagFilter, defaultEvaluateTargetHealth, false, false, nil) zones, err := provider.Zones(t.Context()) require.NoError(t, err) validateAWSZones(t, zones, ti.expectedZones) }) } } func TestAWSZonesWithTagFilterError(t *testing.T) { client := NewRoute53APIStub(t) provider := &AWSProvider{ clients: map[string]Route53API{defaultAWSProfile: client}, zoneTagFilter: provider.NewZoneTagFilter([]string{"zone=2"}), dryRun: false, zonesCache: blueprint.NewZoneCache[map[string]*profiledZone](1 * time.Minute), } createAWSZone(t, provider, &route53types.HostedZone{ Id: aws.String("/hostedzone/zone-1.ext-dns-test-ok.example.com."), Name: aws.String("zone-1.ext-dns-test-ok.example.com."), Config: &route53types.HostedZoneConfig{PrivateZone: false}, }) createAWSZone(t, provider, &route53types.HostedZone{ Id: aws.String("/hostedzone/zone-2.ext-dns-test-error-on-list-tags.example.com."), Name: aws.String("zone-2.ext-dns-test-error-on-list-tags.example.com."), Config: &route53types.HostedZoneConfig{PrivateZone: false}, }) _, err := provider.Zones(t.Context()) require.Error(t, err) require.ErrorContains(t, err, "failed to list tags for zones") } func TestAWSRecordsFilter(t *testing.T) { provider, _ := newAWSProvider(t, &endpoint.DomainFilter{}, provider.ZoneIDFilter{}, provider.ZoneTypeFilter{}, false, false, false, nil) domainFilter := provider.GetDomainFilter() require.NotNil(t, domainFilter) require.IsType(t, &endpoint.DomainFilter{}, domainFilter) count := 0 filters := domainFilter.(*endpoint.DomainFilter).Filters for _, tld := range []string{ "zone-4.ext-dns-test-3.teapot.zalan.do", ".zone-4.ext-dns-test-3.teapot.zalan.do", "zone-2.ext-dns-test-2.teapot.zalan.do", ".zone-2.ext-dns-test-2.teapot.zalan.do", "zone-3.ext-dns-test-2.teapot.zalan.do", ".zone-3.ext-dns-test-2.teapot.zalan.do", "zone-4.ext-dns-test-3.teapot.zalan.do", ".zone-4.ext-dns-test-3.teapot.zalan.do", } { assert.Contains(t, filters, tld) count++ } assert.Len(t, filters, count) } func TestAWSRecords(t *testing.T) { provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), false, false, false, []route53types.ResourceRecordSet{ { Name: aws.String("list-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, }, { Name: aws.String("list-test.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, }, { Name: aws.String(wildcardEscape("*.wildcard-test.zone-2.ext-dns-test-2.teapot.zalan.do.")), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, }, { Name: aws.String(specialCharactersEscape("escape-%!s()-codes.zone-2.ext-dns-test-2.teapot.zalan.do.")), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("example")}}, }, { Name: aws.String(specialCharactersEscape("escape-%!s()-codes-a.zone-2.ext-dns-test-2.teapot.zalan.do.")), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, }, { Name: aws.String(specialCharactersEscape("escape-%!s()-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do.")), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("escape-codes.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: false, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String(specialCharactersEscape("escape-%!s()-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do.")), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("escape-codes.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: false, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: false, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: false, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: false, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: false, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("prefix-*.wildcard.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeTxt, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("random")}}, }, { Name: aws.String("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set-1"), Weight: aws.Int64(10), }, { Name: aws.String("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}}, SetIdentifier: aws.String("test-set-2"), Weight: aws.Int64(20), }, { Name: aws.String("latency-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set"), Region: route53types.ResourceRecordSetRegionUsEast1, }, { Name: aws.String("failover-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set"), Failover: route53types.ResourceRecordSetFailoverPrimary, }, { Name: aws.String("multi-value-answer-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set"), MultiValueAnswer: aws.Bool(true), }, { Name: aws.String("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set-1"), GeoLocation: &route53types.GeoLocation{ ContinentCode: aws.String("EU"), }, }, { Name: aws.String("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}}, SetIdentifier: aws.String("test-set-2"), GeoLocation: &route53types.GeoLocation{ CountryCode: aws.String("DE"), }, }, { Name: aws.String("geolocation-subdivision-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set-1"), GeoLocation: &route53types.GeoLocation{ SubdivisionCode: aws.String("NY"), }, }, { Name: aws.String("geoproximitylocation-region.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set-1"), GeoProximityLocation: &route53types.GeoProximityLocation{ AWSRegion: aws.String("us-west-2"), Bias: aws.Int32(10), }, }, { Name: aws.String("geoproximitylocation-localzone.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set-1"), GeoProximityLocation: &route53types.GeoProximityLocation{ LocalZoneGroup: aws.String("usw2-pdx1-az1"), Bias: aws.Int32(10), }, }, { Name: aws.String("geoproximitylocation-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("test-set-1"), GeoProximityLocation: &route53types.GeoProximityLocation{ Coordinates: &route53types.Coordinates{ Latitude: aws.String("90"), Longitude: aws.String("90"), }, Bias: aws.Int32(0), }, }, { Name: aws.String("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("foo.example.com")}}, SetIdentifier: aws.String("test-set-1"), HealthCheckId: aws.String("foo-bar-healthcheck-id"), Weight: aws.Int64(10), }, { Name: aws.String("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}}, SetIdentifier: aws.String("test-set-2"), HealthCheckId: aws.String("abc-def-healthcheck-id"), Weight: aws.Int64(20), }, { Name: aws.String("mail.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeMx, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mailhost1.example.com")}, {Value: aws.String("20 mailhost2.example.com")}}, }, { Name: aws.String("naptr.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeNaptr, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com`)}, {Value: aws.String(`10 "U" "SIPS+D2T" "" _sips._tcp.sip1.example.com`)}}, }, }) records, err := provider.Records(t.Context()) require.NoError(t, err) validateEndpoints(t, provider, records, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("list-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4"), endpoint.NewEndpointWithTTL("list-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8"), endpoint.NewEndpointWithTTL("*.wildcard-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8"), endpoint.NewEndpointWithTTL("escape-%!s()-codes.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "example").WithProviderSpecific(providerSpecificAlias, "false"), endpoint.NewEndpointWithTTL("escape-%!s()-codes-a.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4"), endpoint.NewEndpointWithTTL("escape-%!s()-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "escape-codes.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("escape-%!s()-codes-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), "escape-codes.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, endpoint.TTL(defaultTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8", "8.8.4.4"), endpoint.NewEndpointWithTTL("prefix-*.wildcard.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), "random"), endpoint.NewEndpointWithTTL("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificWeight, "10"), endpoint.NewEndpointWithTTL("weight-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "4.3.2.1").WithSetIdentifier("test-set-2").WithProviderSpecific(providerSpecificWeight, "20"), endpoint.NewEndpointWithTTL("latency-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set").WithProviderSpecific(providerSpecificRegion, "us-east-1"), endpoint.NewEndpointWithTTL("failover-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set").WithProviderSpecific(providerSpecificFailover, "PRIMARY"), endpoint.NewEndpointWithTTL("multi-value-answer-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set").WithProviderSpecific(providerSpecificMultiValueAnswer, ""), endpoint.NewEndpointWithTTL("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeolocationContinentCode, "EU"), endpoint.NewEndpointWithTTL("geolocation-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "4.3.2.1").WithSetIdentifier("test-set-2").WithProviderSpecific(providerSpecificGeolocationCountryCode, "DE"), endpoint.NewEndpointWithTTL("geolocation-subdivision-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeolocationSubdivisionCode, "NY"), endpoint.NewEndpointWithTTL("geoproximitylocation-region.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"), endpoint.NewEndpointWithTTL("geoproximitylocation-localzone.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-pdx1-az1").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"), endpoint.NewEndpointWithTTL("geoproximitylocation-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.2.3.4").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, "90,90").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "0"), endpoint.NewEndpointWithTTL("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "foo.example.com").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificWeight, "10").WithProviderSpecific(providerSpecificHealthCheckID, "foo-bar-healthcheck-id").WithProviderSpecific(providerSpecificAlias, "false"), endpoint.NewEndpointWithTTL("healthcheck-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "4.3.2.1").WithSetIdentifier("test-set-2").WithProviderSpecific(providerSpecificWeight, "20").WithProviderSpecific(providerSpecificHealthCheckID, "abc-def-healthcheck-id"), endpoint.NewEndpointWithTTL("mail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, endpoint.TTL(defaultTTL), "10 mailhost1.example.com", "20 mailhost2.example.com"), endpoint.NewEndpointWithTTL("naptr.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, endpoint.TTL(defaultTTL), `10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com`, `10 "U" "SIPS+D2T" "" _sips._tcp.sip1.example.com`), }) } func TestAWSRecordsSoftError(t *testing.T) { pvd, subClient := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), false, false, false, []route53types.ResourceRecordSet{ { Name: aws.String("list-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, }, }) subClient.MockMethod("ListResourceRecordSets", mock.Anything).Return(nil, fmt.Errorf("Mock route53 failure")) _, err := pvd.Records(t.Context()) require.Error(t, err) require.ErrorIs(t, err, provider.SoftError) } func TestAWSAdjustEndpoints(t *testing.T) { provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil) records := []*endpoint.Endpoint{ endpoint.NewEndpoint("a-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("cname-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.example.com"), endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, 60, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, 60, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com"), endpoint.NewEndpoint("cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "false"), endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), endpoint.NewEndpoint("a-test-geoproximity-no-bias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2"), } records, err := provider.AdjustEndpoints(records) require.NoError(t, err) validateEndpoints(t, provider, records, []*endpoint.Endpoint{ endpoint.NewEndpoint("a-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("cname-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.example.com").WithProviderSpecific(providerSpecificAlias, "false"), endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, 300, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), endpoint.NewEndpointWithTTL("cname-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, 300, "alias-target.zone-2.ext-dns-test-2.teapot.zalan.do").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), endpoint.NewEndpoint("cname-test-elb.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), endpoint.NewEndpoint("cname-test-elb-no-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "false"), endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health endpoint.NewEndpoint("cname-test-elb-no-eth.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), // eth = evaluate target health endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), endpoint.NewEndpoint("cname-test-elb-alias.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), endpoint.NewEndpoint("a-test-geoproximity-no-bias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8").WithSetIdentifier("test-set-1").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "0"), }) } func TestAWSApplyChanges(t *testing.T) { tests := []struct { name string setup func(p *AWSProvider) context.Context listRRSets int }{ {"no cache", func(_ *AWSProvider) context.Context { return t.Context() }, 0}, {"cached", func(p *AWSProvider) context.Context { ctx := t.Context() records, err := p.Records(ctx) require.NoError(t, err) return context.WithValue(ctx, provider.RecordsContextKey, records) }, 0}, } for _, tt := range tests { provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, []route53types.ResourceRecordSet{ { Name: aws.String("update-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, }, { Name: aws.String("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}}, }, { Name: aws.String("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, }, { Name: aws.String("delete-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}}, }, { Name: aws.String("update-test.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}}, }, { Name: aws.String("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("delete-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}}, }, { Name: aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.1.1.1")}}, }, { Name: aws.String("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("bar.elb.amazonaws.com")}}, }, { Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("bar.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("bar.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("qux.elb.amazonaws.com")}}, }, { Name: aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("qux.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("qux.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, }, { Name: aws.String("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}, {Value: aws.String("4.3.2.1")}}, }, { Name: aws.String("delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}, {Value: aws.String("2606:4700:4700::1001")}}, }, { Name: aws.String("delete-test-geoproximity.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("geoproximity-delete"), GeoProximityLocation: &route53types.GeoProximityLocation{ AWSRegion: aws.String("us-west-2"), Bias: aws.Int32(10), }, }, { Name: aws.String("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("geoproximity-update"), GeoProximityLocation: &route53types.GeoProximityLocation{ LocalZoneGroup: aws.String("usw2-lax1-az2"), }, }, { Name: aws.String("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("weighted-to-simple"), Weight: aws.Int64(10), }, { Name: aws.String("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, }, { Name: aws.String("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("policy-change"), Weight: aws.Int64(10), }, { Name: aws.String("set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("before"), Weight: aws.Int64(10), }, { Name: aws.String("set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("no-change"), Weight: aws.Int64(10), }, { Name: aws.String("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeMx, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mailhost2.bar.elb.amazonaws.com")}}, }, { Name: aws.String("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeMx, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("30 mailhost1.foo.elb.amazonaws.com")}}, }, { Name: aws.String(specialCharactersEscape("escape-%!s()-codes.zone-2.ext-dns-test-2.teapot.zalan.do.")), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("no-change"), Weight: aws.Int64(10), }, { Name: aws.String("delete-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeNaptr, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{ {Value: aws.String(`10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`)}, }, }, { Name: aws.String("update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeNaptr, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`)}}, }, }) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("create-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"), endpoint.NewEndpoint("create-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"), endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), endpoint.NewEndpoint("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), endpoint.NewEndpoint("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"), endpoint.NewEndpoint("create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"), endpoint.NewEndpoint("create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mailhost1.foo.elb.amazonaws.com"), endpoint.NewEndpoint("create-test-naptr.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, `10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`), endpoint.NewEndpoint("create-test-geoproximity-region.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"). WithSetIdentifier("geoproximity-region"). WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2"). WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"), endpoint.NewEndpoint("create-test-geoproximity-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"). WithSetIdentifier("geoproximity-coordinates"). WithProviderSpecific(providerSpecificGeoProximityLocationCoordinates, "60,60"), } currentRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"), endpoint.NewEndpoint("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"), endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.1.1.1"), endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "bar.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "bar.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"), endpoint.NewEndpoint("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"), endpoint.NewEndpoint("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"). WithSetIdentifier("geoproximity-update"). WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-lax1-az2"), endpoint.NewEndpoint("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("weighted-to-simple").WithProviderSpecific(providerSpecificWeight, "10"), endpoint.NewEndpoint("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithProviderSpecific(providerSpecificWeight, "10"), endpoint.NewEndpoint("set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("before").WithProviderSpecific(providerSpecificWeight, "10"), endpoint.NewEndpoint("set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("no-change").WithProviderSpecific(providerSpecificWeight, "10"), endpoint.NewEndpoint("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mailhost2.bar.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, `10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`), endpoint.NewEndpoint("escape-%!s()-codes.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithSetIdentifier("no-change").WithProviderSpecific(providerSpecificWeight, "10"), } updatedRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "4.3.2.1"), endpoint.NewEndpoint("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"), endpoint.NewEndpoint("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"), endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "foo.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "foo.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "my-internal-host.example.com"), endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "baz.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "baz.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"), endpoint.NewEndpoint("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001", "2606:4700:4700::1111"), endpoint.NewEndpoint("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"). WithSetIdentifier("geoproximity-update"). WithProviderSpecific(providerSpecificGeoProximityLocationLocalZoneGroup, "usw2-phx2-az1"), endpoint.NewEndpoint("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("simple-to-weighted").WithProviderSpecific(providerSpecificWeight, "10"), endpoint.NewEndpoint("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("policy-change").WithProviderSpecific(providerSpecificRegion, "us-east-1"), endpoint.NewEndpoint("set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("after").WithProviderSpecific(providerSpecificWeight, "10"), endpoint.NewEndpoint("set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("no-change").WithProviderSpecific(providerSpecificWeight, "20"), endpoint.NewEndpoint("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "20 mailhost3.foo.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, `20 "U" "SIP+DTU" "" _sip._udp.sip2.example.com.`), } deleteRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("delete-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111"), endpoint.NewEndpoint("delete-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1001"), endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "qux.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "qux.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true"), endpoint.NewEndpoint("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"), endpoint.NewEndpoint("delete-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeAAAA, "2606:4700:4700::1111", "2606:4700:4700::1001"), endpoint.NewEndpoint("delete-test-geoproximity.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("geoproximity-delete").WithProviderSpecific(providerSpecificGeoProximityLocationAWSRegion, "us-west-2").WithProviderSpecific(providerSpecificGeoProximityLocationBias, "10"), endpoint.NewEndpoint("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "30 mailhost1.foo.elb.amazonaws.com"), endpoint.NewEndpoint("delete-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeNAPTR, `10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`), } changes := &plan.Changes{ Create: createRecords, UpdateNew: updatedRecords, UpdateOld: currentRecords, Delete: deleteRecords, } ctx := tt.setup(provider) provider.zonesCache = blueprint.NewZoneCache[map[string]*profiledZone](0 * time.Minute) counter := NewRoute53APICounter(provider.clients[defaultAWSProfile]) provider.clients[defaultAWSProfile] = counter require.NoError(t, provider.ApplyChanges(ctx, changes)) assert.Equal(t, 1, counter.calls["ListHostedZonesPages"], tt.name) assert.Equal(t, tt.listRRSets, counter.calls["ListResourceRecordSetsPages"], tt.name) validateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), []route53types.ResourceRecordSet{ { Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, }, { Name: aws.String("create-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}}, }, { Name: aws.String("update-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, }, { Name: aws.String("update-test-aaaa.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}}, }, { Name: aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."), }, }, { Name: aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."), }, }, { Name: aws.String("update-test-alias-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("my-internal-host.example.com")}}, }, { Name: aws.String("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("foo.elb.amazonaws.com")}}, }, { Name: aws.String("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("baz.elb.amazonaws.com")}}, }, { Name: aws.String("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("foo.elb.amazonaws.com")}}, }, { Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("baz.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."), }, }, { Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("baz.elb.amazonaws.com."), EvaluateTargetHealth: true, HostedZoneId: aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."), }, }, { Name: aws.String("weighted-to-simple.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, }, { Name: aws.String("simple-to-weighted.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("simple-to-weighted"), Weight: aws.Int64(10), }, { Name: aws.String("policy-change.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("policy-change"), Region: route53types.ResourceRecordSetRegionUsEast1, }, { Name: aws.String("set-identifier-change.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("after"), Weight: aws.Int64(10), }, { Name: aws.String("set-identifier-no-change.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("no-change"), Weight: aws.Int64(20), }, { Name: aws.String("create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeMx, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mailhost1.foo.elb.amazonaws.com")}}, }, { Name: aws.String("create-test-naptr.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeNaptr, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`10 "U" "SIP+DTU" "" _sip._udp.sip1.example.com.`)}}, }, { Name: aws.String("create-test-geoproximity-region.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, SetIdentifier: aws.String("geoproximity-region"), GeoProximityLocation: &route53types.GeoProximityLocation{ AWSRegion: aws.String("us-west-2"), Bias: aws.Int32(10), }, }, { Name: aws.String("update-test-geoproximity.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("geoproximity-update"), GeoProximityLocation: &route53types.GeoProximityLocation{ LocalZoneGroup: aws.String("usw2-phx2-az1"), }, }, { Name: aws.String("create-test-geoproximity-coordinates.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, SetIdentifier: aws.String("geoproximity-coordinates"), GeoProximityLocation: &route53types.GeoProximityLocation{ Coordinates: &route53types.Coordinates{ Latitude: aws.String("60"), Longitude: aws.String("60"), }, }, }, }) validateRecords(t, listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."), []route53types.ResourceRecordSet{ { Name: aws.String("escape-\\045\\041s\\050\\074nil\\076\\051-codes.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}}, SetIdentifier: aws.String("no-change"), Weight: aws.Int64(10), }, { Name: aws.String("create-test.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("create-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}}, }, { Name: aws.String("update-test.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("4.3.2.1")}}, }, { Name: aws.String("update-test-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}}, }, { Name: aws.String("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("create-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1111")}, {Value: aws.String("2606:4700:4700::1001")}}, }, { Name: aws.String("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}, {Value: aws.String("4.3.2.1")}}, }, { Name: aws.String("update-test-multiple-aaaa.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("2606:4700:4700::1001")}, {Value: aws.String("2606:4700:4700::1111")}}, }, { Name: aws.String("update-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeMx, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("20 mailhost3.foo.elb.amazonaws.com")}}, }, { Name: aws.String("update-test-naptr.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeNaptr, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String(`20 "U" "SIP+DTU" "" _sip._udp.sip2.example.com.`)}}, }, }) } } func TestAWSApplyChangesDryRun(t *testing.T) { originalRecords := []route53types.ResourceRecordSet{ { Name: aws.String("update-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, }, { Name: aws.String("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}}, }, { Name: aws.String("update-test.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.1.1.1")}}, }, { Name: aws.String("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("bar.elb.amazonaws.com")}}, }, { Name: aws.String("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("qux.elb.amazonaws.com")}}, }, { Name: aws.String("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("bar.elb.amazonaws.com")}}, }, { Name: aws.String("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("qux.elb.amazonaws.com")}}, }, { Name: aws.String("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("8.8.8.8")}, {Value: aws.String("8.8.4.4")}}, }, { Name: aws.String("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("1.2.3.4")}, {Value: aws.String("4.3.2.1")}}, }, { Name: aws.String("update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeMx, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("20 mail.foo.elb.amazonaws.com")}}, }, { Name: aws.String("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeMx, TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mail.bar.elb.amazonaws.com")}}, }, } provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, true, originalRecords) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), endpoint.NewEndpoint("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), endpoint.NewEndpoint("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"), endpoint.NewEndpoint("create-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "30 mail.foo.elb.amazonaws.com"), } currentRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.1.1.1"), endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"), endpoint.NewEndpoint("update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "20 mail.foo.elb.amazonaws.com"), } updatedRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "4.3.2.1"), endpoint.NewEndpoint("update-test-a-to-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"), endpoint.NewEndpoint("update-test-mx.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mail.bar.elb.amazonaws.com"), } deleteRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), endpoint.NewEndpoint("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"), endpoint.NewEndpoint("delete-test-mx.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeMX, "10 mail.bar.elb.amazonaws.com"), } changes := &plan.Changes{ Create: createRecords, UpdateNew: updatedRecords, UpdateOld: currentRecords, Delete: deleteRecords, } ctx := t.Context() require.NoError(t, provider.ApplyChanges(ctx, changes)) validateRecords(t, append( listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.")...), originalRecords) } func TestAWSChangesByZones(t *testing.T) { changes := Route53Changes{ { Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("qux.foo.example.org"), TTL: aws.Int64(1), }, }, }, { Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("qux.bar.example.org"), TTL: aws.Int64(2), }, }, }, { Change: route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("wambo.foo.example.org"), TTL: aws.Int64(10), }, }, }, { Change: route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("wambo.bar.example.org"), TTL: aws.Int64(20), }, }, }, } zones := map[string]*profiledZone{ "foo-example-org": { profile: defaultAWSProfile, zone: &route53types.HostedZone{ Id: aws.String("foo-example-org"), Name: aws.String("foo.example.org."), }, }, "bar-example-org": { profile: defaultAWSProfile, zone: &route53types.HostedZone{ Id: aws.String("bar-example-org"), Name: aws.String("bar.example.org."), }, }, "bar-example-org-private": { profile: defaultAWSProfile, zone: &route53types.HostedZone{ Id: aws.String("bar-example-org-private"), Name: aws.String("bar.example.org."), Config: &route53types.HostedZoneConfig{PrivateZone: true}, }, }, "baz-example-org": { profile: defaultAWSProfile, zone: &route53types.HostedZone{ Id: aws.String("baz-example-org"), Name: aws.String("baz.example.org."), }, }, } changesByZone := changesByZone(zones, changes) require.Len(t, changesByZone, 3) validateAWSChangeRecords(t, changesByZone["foo-example-org"], Route53Changes{ { Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("qux.foo.example.org"), TTL: aws.Int64(1), }, }, }, { Change: route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("wambo.foo.example.org"), TTL: aws.Int64(10), }, }, }, }) validateAWSChangeRecords(t, changesByZone["bar-example-org"], Route53Changes{ { Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("qux.bar.example.org"), TTL: aws.Int64(2), }, }, }, { Change: route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("wambo.bar.example.org"), TTL: aws.Int64(20), }, }, }, }) validateAWSChangeRecords(t, changesByZone["bar-example-org-private"], Route53Changes{ { Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("qux.bar.example.org"), TTL: aws.Int64(2), }, }, }, { Change: route53types.Change{ Action: route53types.ChangeActionDelete, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String("wambo.bar.example.org"), TTL: aws.Int64(20), }, }, }, }) } func TestAWSsubmitChanges(t *testing.T) { provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil) const subnets = 16 const hosts = defaultBatchChangeSize / subnets endpoints := make([]*endpoint.Endpoint, 0) for i := range subnets { for j := 1; j < (hosts + 1); j++ { hostname := fmt.Sprintf("subnet%dhost%d.zone-1.ext-dns-test-2.teapot.zalan.do", i, j) ip := fmt.Sprintf("1.1.%d.%d", i, j) ep := endpoint.NewEndpointWithTTL(hostname, endpoint.RecordTypeA, endpoint.TTL(defaultTTL), ip) endpoints = append(endpoints, ep) } } ctx := t.Context() zones, _ := provider.zones(ctx) records, _ := provider.Records(ctx) cs := make(Route53Changes, 0, len(endpoints)) cs = append(cs, provider.newChanges(route53types.ChangeActionCreate, endpoints)...) require.NoError(t, provider.submitChanges(ctx, cs, zones)) records, err := provider.Records(ctx) require.NoError(t, err) validateEndpoints(t, provider, records, endpoints) } func TestAWSsubmitChangesError(t *testing.T) { provider, clientStub := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil) clientStub.MockMethod("ChangeResourceRecordSets", mock.Anything).Return(nil, fmt.Errorf("Mock route53 failure")) ctx := t.Context() zones, err := provider.zones(ctx) require.NoError(t, err) ep := endpoint.NewEndpointWithTTL("fail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.0.0.1") cs := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep}) require.Error(t, provider.submitChanges(ctx, cs, zones)) } func TestAWSsubmitChangesRetryOnError(t *testing.T) { provider, clientStub := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil) ctx := t.Context() zones, err := provider.zones(ctx) require.NoError(t, err) ep1 := endpoint.NewEndpointWithTTL("success.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.0.0.1") ep2 := endpoint.NewEndpointWithTTL("fail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.0.0.2") ep3 := endpoint.NewEndpointWithTTL("success2.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.0.0.3") ep2txt := endpoint.NewEndpointWithTTL("fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), "something") // "__edns_housekeeping" is the TXT suffix ep2txt.Labels = map[string]string{ endpoint.OwnedRecordLabelKey: "fail.zone-1.ext-dns-test-2.teapot.zalan.do", } // "success" and "fail" are created in the first step, both are submitted in the same batch; this should fail cs1 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt, ep1}) input1 := &route53.ChangeResourceRecordSetsInput{ HostedZoneId: aws.String("/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), ChangeBatch: &route53types.ChangeBatch{ Changes: cs1.Route53Changes(), }, } clientStub.MockMethod("ChangeResourceRecordSets", input1).Return(nil, fmt.Errorf("Mock route53 failure")) // because of the failure, changes will be retried one by one; make "fail" submitted in its own batch fail as well cs2 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt}) input2 := &route53.ChangeResourceRecordSetsInput{ HostedZoneId: aws.String("/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), ChangeBatch: &route53types.ChangeBatch{ Changes: cs2.Route53Changes(), }, } clientStub.MockMethod("ChangeResourceRecordSets", input2).Return(nil, fmt.Errorf("Mock route53 failure")) // "success" should have been created, verify that we still get an error because "fail" failed require.Error(t, provider.submitChanges(ctx, cs1, zones)) // assert that "success" was successfully created and "fail" and its TXT record were not records, err := provider.Records(ctx) require.NoError(t, err) require.True(t, containsRecordWithDNSName(records, "success.zone-1.ext-dns-test-2.teapot.zalan.do")) require.False(t, containsRecordWithDNSName(records, "fail.zone-1.ext-dns-test-2.teapot.zalan.do")) require.False(t, containsRecordWithDNSName(records, "fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do")) // next batch should contain "fail" and "success2", should succeed this time cs3 := provider.newChanges(route53types.ChangeActionCreate, []*endpoint.Endpoint{ep2, ep2txt, ep3}) require.NoError(t, provider.submitChanges(ctx, cs3, zones)) // verify all records are there records, err = provider.Records(ctx) require.NoError(t, err) require.True(t, containsRecordWithDNSName(records, "success.zone-1.ext-dns-test-2.teapot.zalan.do")) require.True(t, containsRecordWithDNSName(records, "fail.zone-1.ext-dns-test-2.teapot.zalan.do")) require.True(t, containsRecordWithDNSName(records, "success2.zone-1.ext-dns-test-2.teapot.zalan.do")) require.True(t, containsRecordWithDNSName(records, "fail__edns_housekeeping.zone-1.ext-dns-test-2.teapot.zalan.do")) } func TestAWSBatchChangeSet(t *testing.T) { var cs Route53Changes for i := 1; i <= defaultBatchChangeSize; i += 2 { cs = append(cs, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeA, }, }, }) cs = append(cs, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeTxt, }, }, }) } batchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues) require.Len(t, batchCs, 1) // sorting cs not needed as it should be returned as is validateAWSChangeRecords(t, batchCs[0], cs) } func TestAWSBatchChangeSetExceeding(t *testing.T) { var cs Route53Changes const testCount = 50 const testLimit = 11 const expectedBatchCount = 5 const expectedChangesCount = 10 for i := 1; i <= testCount; i += 2 { cs = append(cs, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeA, }, }, }, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeTxt, }, }, }, ) } batchCs := batchChangeSet(cs, testLimit, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues) require.Len(t, batchCs, expectedBatchCount) // sorting cs needed to match batchCs for i, batch := range batchCs { validateAWSChangeRecords(t, batch, sortChangesByActionNameType(cs)[i*expectedChangesCount:expectedChangesCount*(i+1)]) } } func TestAWSBatchChangeSetExceedingNameChange(t *testing.T) { var cs Route53Changes const testCount = 10 const testLimit = 1 for i := 1; i <= testCount; i += 2 { cs = append(cs, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeA, }, }, }, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeTxt, }, }, }, ) } batchCs := batchChangeSet(cs, testLimit, defaultBatchChangeSizeBytes, defaultBatchChangeSizeValues) require.Empty(t, batchCs) } func TestAWSBatchChangeSetExceedingBytesLimit(t *testing.T) { const ( testCount = 50 testLimit = 100 groupSize = 2 ) var ( cs Route53Changes // Bytes for each name testBytes = len([]byte("1.2.3.4")) + len([]byte("test-record")) // testCount / groupSize / (testLimit // bytes) expectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testBytes) // Round up expectedBatchCount = int(math.Ceil(expectedBatchCountFloat)) ) for i := 1; i <= testCount; i += groupSize { cs = append(cs, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeA, ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("1.2.3.4"), }, }, }, }, sizeBytes: len([]byte("1.2.3.4")), sizeValues: 1, }, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeTxt, ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("txt-record"), }, }, }, }, sizeBytes: len([]byte("txt-record")), sizeValues: 1, }, ) } batchCs := batchChangeSet(cs, defaultBatchChangeSize, testLimit, defaultBatchChangeSizeValues) require.Len(t, batchCs, expectedBatchCount) } func TestAWSBatchChangeSetExceedingBytesLimitUpsert(t *testing.T) { const ( testCount = 50 testLimit = 100 groupSize = 2 ) var ( cs Route53Changes // Bytes for each name multiplied by 2 for Upsert records testBytes = (len([]byte("1.2.3.4")) + len([]byte("test-record"))) * 2 // testCount / groupSize / (testLimit // bytes) expectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testBytes) // Round up expectedBatchCount = int(math.Ceil(expectedBatchCountFloat)) ) for i := 1; i <= testCount; i += groupSize { cs = append(cs, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionUpsert, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeA, ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("1.2.3.4"), }, }, }, }, sizeBytes: len([]byte("1.2.3.4")) * 2, sizeValues: 1, }, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionUpsert, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeTxt, ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("txt-record"), }, }, }, }, sizeBytes: len([]byte("txt-record")) * 2, sizeValues: 1, }, ) } batchCs := batchChangeSet(cs, defaultBatchChangeSize, testLimit, defaultBatchChangeSizeValues) require.Len(t, batchCs, expectedBatchCount) } func TestAWSBatchChangeSetExceedingValuesLimit(t *testing.T) { const ( testCount = 50 testLimit = 100 groupSize = 2 // Values for each group testValues = 2 ) var ( cs Route53Changes // testCount / groupSize / (testLimit // bytes) expectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testValues) // Round up expectedBatchCount = int(math.Ceil(expectedBatchCountFloat)) ) for i := 1; i <= testCount; i += groupSize { cs = append(cs, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeA, ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("1.2.3.4"), }, }, }, }, sizeBytes: len([]byte("1.2.3.4")), sizeValues: 1, }, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeTxt, ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("txt-record"), }, }, }, }, sizeBytes: len([]byte("txt-record")), sizeValues: 1, }, ) } batchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, testLimit) require.Len(t, batchCs, expectedBatchCount) } func TestAWSBatchChangeSetExceedingValuesLimitUpsert(t *testing.T) { const ( testCount = 50 testLimit = 100 groupSize = 2 // Values for each group multiplied by 2 for Upsert records testValues = 2 * 2 ) var ( cs Route53Changes // testCount / groupSize / (testLimit // bytes) expectedBatchCountFloat = float64(testCount) / float64(groupSize) / float64(testLimit/testValues) // Round up expectedBatchCount = int(math.Ceil(expectedBatchCountFloat)) ) for i := 1; i <= testCount; i += groupSize { cs = append(cs, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionUpsert, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeA, ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("1.2.3.4"), }, }, }, }, sizeBytes: len([]byte("1.2.3.4")) * 2, sizeValues: 1, }, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionUpsert, ResourceRecordSet: &route53types.ResourceRecordSet{ Name: aws.String(fmt.Sprintf("host-%d", i)), Type: route53types.RRTypeTxt, ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("txt-record"), }, }, }, }, sizeBytes: len([]byte("txt-record")) * 2, sizeValues: 1, }, ) } batchCs := batchChangeSet(cs, defaultBatchChangeSize, defaultBatchChangeSizeBytes, testLimit) require.Len(t, batchCs, expectedBatchCount) } func validateEndpoints(t *testing.T, provider *AWSProvider, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) { assert.True(t, testutils.SameEndpoints(endpoints, expected), "actual and expected endpoints don't match. %+v:%+v", endpoints, expected) normalized, err := provider.AdjustEndpoints(endpoints) assert.NoError(t, err) assert.True(t, testutils.SameEndpoints(normalized, expected), "normalized and expected endpoints don't match. %+v:%+v", normalized, expected) } func validateAWSZones(t *testing.T, zones map[string]*route53types.HostedZone, expected map[string]*route53types.HostedZone) { require.Len(t, zones, len(expected)) for i, zone := range zones { validateAWSZone(t, zone, expected[i]) } } func validateAWSZone(t *testing.T, zone *route53types.HostedZone, expected *route53types.HostedZone) { assert.Equal(t, *expected.Id, *zone.Id) assert.Equal(t, *expected.Name, *zone.Name) } func validateAWSChangeRecords(t *testing.T, records Route53Changes, expected Route53Changes) { require.Len(t, records, len(expected)) for i := range records { validateAWSChangeRecord(t, records[i], expected[i]) } } func validateAWSChangeRecord(t *testing.T, record *Route53Change, expected *Route53Change) { assert.Equal(t, expected.Action, record.Action) assert.Equal(t, *expected.ResourceRecordSet.Name, *record.ResourceRecordSet.Name) assert.Equal(t, expected.ResourceRecordSet.Type, record.ResourceRecordSet.Type) } func TestAWSCreateRecordsWithCNAME(t *testing.T) { provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil) records := []*endpoint.Endpoint{ {DNSName: "create-test.zone-1.ext-dns-test-2.teapot.zalan.do", Targets: endpoint.Targets{"foo.example.org"}, RecordType: endpoint.RecordTypeCNAME}, } adjusted, err := provider.AdjustEndpoints(records) require.NoError(t, err) require.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{ Create: adjusted, })) recordSets := listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.") validateRecords(t, recordSets, []route53types.ResourceRecordSet{ { Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeCname, TTL: aws.Int64(300), ResourceRecords: []route53types.ResourceRecord{ { Value: aws.String("foo.example.org"), }, }, }, }) } func TestAWSCreateRecordsWithALIAS(t *testing.T) { for key, evaluateTargetHealth := range map[string]bool{ "true": true, "false": false, "": false, } { provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil) records := []*endpoint.Endpoint{ { DNSName: "create-test.zone-1.ext-dns-test-2.teapot.zalan.do", Targets: endpoint.Targets{"foo.eu-central-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeA, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: providerSpecificAlias, Value: "true", }, endpoint.ProviderSpecificProperty{ Name: providerSpecificEvaluateTargetHealth, Value: key, }, }, }, { DNSName: "create-test.zone-1.ext-dns-test-2.teapot.zalan.do", Targets: endpoint.Targets{"foo.eu-central-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeAAAA, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: providerSpecificAlias, Value: "true", }, endpoint.ProviderSpecificProperty{ Name: providerSpecificEvaluateTargetHealth, Value: key, }, }, }, } adjusted, err := provider.AdjustEndpoints(records) require.NoError(t, err) require.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{ Create: adjusted, })) recordSets := listAWSRecords(t, provider.clients[defaultAWSProfile], "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.") validateRecords(t, recordSets, []route53types.ResourceRecordSet{ { AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: evaluateTargetHealth, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeA, }, { AliasTarget: &route53types.AliasTarget{ DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), EvaluateTargetHealth: evaluateTargetHealth, HostedZoneId: aws.String("Z215JYRZR1TBD5"), }, Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."), Type: route53types.RRTypeAaaa, }, }) } } func TestAWSisLoadBalancer(t *testing.T) { for _, tc := range []struct { target string recordType string preferCNAME bool expected bool }{ {"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeCNAME, false, true}, {"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeCNAME, true, false}, {"foo.example.org", endpoint.RecordTypeCNAME, false, false}, {"foo.example.org", endpoint.RecordTypeCNAME, true, false}, } { ep := &endpoint.Endpoint{ Targets: endpoint.Targets{tc.target}, RecordType: tc.recordType, } assert.Equal(t, tc.expected, useAlias(ep, tc.preferCNAME)) } } func TestAWSisAWSAlias(t *testing.T) { for _, tc := range []struct { target string recordType string alias bool hz string }{ {"foo.example.org", endpoint.RecordTypeA, false, ""}, // normal A record {"foo.example.org", endpoint.RecordTypeAAAA, false, ""}, // normal AAAA record {"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeA, true, "Z215JYRZR1TBD5"}, // pointing to ELB DNS name (alias A) {"bar.eu-central-1.elb.amazonaws.com", endpoint.RecordTypeAAAA, true, "Z215JYRZR1TBD5"}, // pointing to ELB DNS name (alias AAAA) {"foobar.example.org", endpoint.RecordTypeA, true, "Z1234567890ABC"}, // HZID retrieved by Route53 (alias A) {"foobar.example.org", endpoint.RecordTypeAAAA, true, "Z1234567890ABC"}, // HZID retrieved by Route53 (alias AAAA) {"baz.example.org", endpoint.RecordTypeA, true, sameZoneAlias}, // record to be created (alias A) {"baz.example.org", endpoint.RecordTypeAAAA, true, sameZoneAlias}, // record to be created (alias AAAA) } { ep := &endpoint.Endpoint{ Targets: endpoint.Targets{tc.target}, RecordType: tc.recordType, } if tc.alias { ep = ep.WithProviderSpecific(providerSpecificAlias, "true") ep = ep.WithProviderSpecific(providerSpecificTargetHostedZone, tc.hz) } assert.Equal(t, tc.hz, isAWSAlias(ep), "%v", tc) } } func TestAWSCanonicalHostedZone(t *testing.T) { for suffix, id := range canonicalHostedZones { zone := canonicalHostedZone(fmt.Sprintf("foo.%s", suffix)) assert.Equal(t, id, zone, "zone suffix: %s", suffix) } zone := canonicalHostedZone("foo.example.org") assert.Empty(t, zone, "no canonical zone should be returned for a non-aws hostname") } func TestAWSCanonicalHostedZoneNotExist(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) host := "foo.elb.eastwest-1.amazonaws.com" _ = canonicalHostedZone(host) assert.Containsf(t, buf.String(), "Could not find canonical hosted zone for domain", host) } func BenchmarkTestAWSCanonicalHostedZone(b *testing.B) { for b.Loop() { for suffix := range canonicalHostedZones { _ = canonicalHostedZone(fmt.Sprintf("foo.%s", suffix)) } } } func BenchmarkTestAWSNonCanonicalHostedZone(b *testing.B) { for b.Loop() { for range canonicalHostedZones { _ = canonicalHostedZone("extremely.long.zone-2.ext.dns.test.zone.non.canonical.example.com") } } } func TestAWSSuitableZones(t *testing.T) { zones := map[string]*profiledZone{ // Public domain "example-org": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("example-org"), Name: aws.String("example.org.")}}, // Public subdomain "bar-example-org": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("bar-example-org"), Name: aws.String("bar.example.org."), Config: &route53types.HostedZoneConfig{PrivateZone: false}}}, // Public subdomain "longfoo-bar-example-org": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("longfoo-bar-example-org"), Name: aws.String("longfoo.bar.example.org.")}}, // Private domain "example-org-private": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("example-org-private"), Name: aws.String("example.org."), Config: &route53types.HostedZoneConfig{PrivateZone: true}}}, // Private subdomain "bar-example-org-private": {profile: defaultAWSProfile, zone: &route53types.HostedZone{Id: aws.String("bar-example-org-private"), Name: aws.String("bar.example.org."), Config: &route53types.HostedZoneConfig{PrivateZone: true}}}, } for _, tc := range []struct { hostname string expected []*profiledZone }{ // bar.example.org is NOT suitable {"foobar.example.org.", []*profiledZone{zones["example-org-private"], zones["example-org"]}}, // all matching private zones are suitable // https://github.com/kubernetes-sigs/external-dns/pull/356 {"bar.example.org.", []*profiledZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}}, {"foo.bar.example.org.", []*profiledZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}}, {"foo.example.org.", []*profiledZone{zones["example-org-private"], zones["example-org"]}}, {"foo.kubernetes.io.", nil}, } { suitableZones := suitableZones(tc.hostname, zones) sort.Slice(suitableZones, func(i, j int) bool { return *suitableZones[i].zone.Id < *suitableZones[j].zone.Id }) sort.Slice(tc.expected, func(i, j int) bool { return *tc.expected[i].zone.Id < *tc.expected[j].zone.Id }) assert.Equal(t, tc.expected, suitableZones) } } func createAWSZone(t *testing.T, provider *AWSProvider, zone *route53types.HostedZone) { params := &route53.CreateHostedZoneInput{ CallerReference: aws.String("external-dns.alpha.kubernetes.io/test-zone"), Name: zone.Name, HostedZoneConfig: zone.Config, } if _, err := provider.clients[defaultAWSProfile].CreateHostedZone(t.Context(), params); err != nil { var hzExists *route53types.HostedZoneAlreadyExists require.ErrorAs(t, err, &hzExists) } } func setAWSRecords(t *testing.T, provider *AWSProvider, records []route53types.ResourceRecordSet) { dryRun := provider.dryRun provider.dryRun = false defer func() { provider.dryRun = dryRun }() ctx := t.Context() endpoints, err := provider.Records(ctx) require.NoError(t, err) validateEndpoints(t, provider, endpoints, []*endpoint.Endpoint{}) var changes Route53Changes for _, record := range records { changes = append(changes, &Route53Change{ Change: route53types.Change{ Action: route53types.ChangeActionCreate, ResourceRecordSet: &record, }, }) } zones, err := provider.zones(ctx) require.NoError(t, err) err = provider.submitChanges(ctx, changes, zones) require.NoError(t, err) _, err = provider.Records(ctx) require.NoError(t, err) } func listAWSRecords(t *testing.T, client Route53API, zone string) []route53types.ResourceRecordSet { resp, err := client.ListResourceRecordSets(t.Context(), &route53.ListResourceRecordSetsInput{ HostedZoneId: aws.String(zone), MaxItems: aws.Int32(route53PageSize), }) require.NoError(t, err) return resp.ResourceRecordSets } func newAWSProvider(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, evaluateTargetHealth, preferCNAME, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) { return newAWSProviderWithTagFilter(t, domainFilter, zoneIDFilter, zoneTypeFilter, provider.NewZoneTagFilter([]string{}), evaluateTargetHealth, preferCNAME, dryRun, records) } func newAWSProviderWithTagFilter(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, zoneTagFilter provider.ZoneTagFilter, evaluateTargetHealth, preferCNAME, dryRun bool, records []route53types.ResourceRecordSet) (*AWSProvider, *Route53APIStub) { client := NewRoute53APIStub(t) provider := &AWSProvider{ clients: map[string]Route53API{defaultAWSProfile: client}, batchChangeSize: defaultBatchChangeSize, batchChangeSizeBytes: defaultBatchChangeSizeBytes, batchChangeSizeValues: defaultBatchChangeSizeValues, batchChangeInterval: defaultBatchChangeInterval, evaluateTargetHealth: evaluateTargetHealth, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, zoneTypeFilter: zoneTypeFilter, zoneTagFilter: zoneTagFilter, preferCNAME: preferCNAME, dryRun: false, zonesCache: blueprint.NewZoneCache[map[string]*profiledZone](1 * time.Minute), failedChangesQueue: make(map[string]Route53Changes), } createAWSZone(t, provider, &route53types.HostedZone{ Id: aws.String("/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do."), Name: aws.String("zone-1.ext-dns-test-2.teapot.zalan.do."), Config: &route53types.HostedZoneConfig{PrivateZone: false}, }) createAWSZone(t, provider, &route53types.HostedZone{ Id: aws.String("/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do."), Name: aws.String("zone-2.ext-dns-test-2.teapot.zalan.do."), Config: &route53types.HostedZoneConfig{PrivateZone: false}, }) createAWSZone(t, provider, &route53types.HostedZone{ Id: aws.String("/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."), Name: aws.String("zone-3.ext-dns-test-2.teapot.zalan.do."), Config: &route53types.HostedZoneConfig{PrivateZone: true}, }) // filtered out by domain filter createAWSZone(t, provider, &route53types.HostedZone{ Id: aws.String("/hostedzone/zone-4.ext-dns-test-3.teapot.zalan.do."), Name: aws.String("zone-4.ext-dns-test-3.teapot.zalan.do."), Config: &route53types.HostedZoneConfig{PrivateZone: false}, }) setupZoneTags(provider.clients[defaultAWSProfile].(*Route53APIStub)) setAWSRecords(t, provider, records) provider.dryRun = dryRun return provider, client } func setupZoneTags(client *Route53APIStub) { addZoneTags(client.zoneTags, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.", map[string]string{ "zone-1-tag-1": "tag-1-value", "domain": "test-2", "zone": "1", }) addZoneTags(client.zoneTags, "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.", map[string]string{ "zone-2-tag-1": "tag-1-value", "domain": "test-2", "zone": "2", }) addZoneTags(client.zoneTags, "/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.", map[string]string{ "zone-3-tag-1": "tag-1-value", "domain": "test-2", "zone": "3", }) addZoneTags(client.zoneTags, "/hostedzone/zone-4.ext-dns-test-2.teapot.zalan.do.", map[string]string{ "zone-4-tag-1": "tag-1-value", "domain": "test-3", "zone": "4", }) } func addZoneTags(tagMap map[string][]route53types.Tag, zoneID string, tags map[string]string) { tagList := make([]route53types.Tag, 0, len(tags)) for k, v := range tags { tagList = append(tagList, route53types.Tag{ Key: aws.String(k), Value: aws.String(v), }) } tagMap[zoneID] = tagList } func validateRecords(t *testing.T, records []route53types.ResourceRecordSet, expected []route53types.ResourceRecordSet) { assert.ElementsMatch(t, expected, records) } func containsRecordWithDNSName(records []*endpoint.Endpoint, dnsName string) bool { for _, record := range records { if record.DNSName == dnsName { return true } } return false } func TestRequiresDeleteCreate(t *testing.T) { provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"foo.bar."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, false, nil) oldRecordType := endpoint.NewEndpointWithTTL("recordType", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8") newRecordType := endpoint.NewEndpointWithTTL("recordType", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "bar").WithProviderSpecific(providerSpecificAlias, "false") assert.False(t, provider.requiresDeleteCreate(oldRecordType, oldRecordType), "actual and expected endpoints don't match. %+v:%+v", oldRecordType, oldRecordType) assert.True(t, provider.requiresDeleteCreate(oldRecordType, newRecordType), "actual and expected endpoints don't match. %+v:%+v", oldRecordType, newRecordType) oldAtoAlias := endpoint.NewEndpointWithTTL("AtoAlias", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "1.1.1.1") newAtoAlias := endpoint.NewEndpointWithTTL("AtoAlias", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "bar.us-east-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificAlias, "true") assert.False(t, provider.requiresDeleteCreate(oldAtoAlias, oldAtoAlias), "actual and expected endpoints don't match. %+v:%+v", oldAtoAlias, oldAtoAlias.DNSName) assert.True(t, provider.requiresDeleteCreate(oldAtoAlias, newAtoAlias), "actual and expected endpoints don't match. %+v:%+v", oldAtoAlias, newAtoAlias) oldPolicy := endpoint.NewEndpointWithTTL("policy", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8").WithSetIdentifier("nochange").WithProviderSpecific(providerSpecificRegion, "us-east-1") newPolicy := endpoint.NewEndpointWithTTL("policy", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8").WithSetIdentifier("nochange").WithProviderSpecific(providerSpecificWeight, "10") assert.False(t, provider.requiresDeleteCreate(oldPolicy, oldPolicy), "actual and expected endpoints don't match. %+v:%+v", oldPolicy, oldPolicy) assert.True(t, provider.requiresDeleteCreate(oldPolicy, newPolicy), "actual and expected endpoints don't match. %+v:%+v", oldPolicy, newPolicy) oldSetIdentifier := endpoint.NewEndpointWithTTL("setIdentifier", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8").WithSetIdentifier("old") newSetIdentifier := endpoint.NewEndpointWithTTL("setIdentifier", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "8.8.8.8").WithSetIdentifier("new") assert.False(t, provider.requiresDeleteCreate(oldSetIdentifier, oldSetIdentifier), "actual and expected endpoints don't match. %+v:%+v", oldSetIdentifier, oldSetIdentifier) assert.True(t, provider.requiresDeleteCreate(oldSetIdentifier, newSetIdentifier), "actual and expected endpoints don't match. %+v:%+v", oldSetIdentifier, newSetIdentifier) } func TestConvertOctalToAscii(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "Characters escaped !\"#$%&'()*+,-/:;", input: "txt-\\041\\042\\043\\044\\045\\046\\047\\050\\051\\052\\053\\054-\\057\\072\\073-test.example.com", expected: "txt-!\"#$%&'()*+,-/:;-test.example.com", }, { name: "Characters escaped <=>?@[\\]^_`{|}~", input: "txt-\\074\\075\\076\\077\\100\\133\\134\\135\\136_\\140\\173\\174\\175\\176-test2.example.com", expected: "txt-<=>?@[\\]^_`{|}~-test2.example.com", }, { name: "No escaped characters in domain", input: "txt-awesome-test3.example.com", expected: "txt-awesome-test3.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { actual := convertOctalToAscii(tt.input) assert.Equal(t, tt.expected, actual) }) } } func TestGeoProximityWithAWSRegion(t *testing.T) { tests := []struct { name string region string hasRegion bool expectedSet bool expectedRegion string }{ { name: "valid AWS region", region: "us-west-2", hasRegion: true, expectedSet: true, expectedRegion: "us-west-2", }, { name: "another valid AWS region", region: "eu-central-1", hasRegion: true, expectedSet: true, expectedRegion: "eu-central-1", }, { name: "empty region string", region: "", hasRegion: true, expectedSet: true, expectedRegion: "", }, { name: "no region property set", region: "", hasRegion: false, expectedSet: false, expectedRegion: "", }, { name: "region with special characters", region: "us-gov-west-1", hasRegion: true, expectedSet: true, expectedRegion: "us-gov-west-1", }, { name: "region with numbers", region: "ap-southeast-3", hasRegion: true, expectedSet: true, expectedRegion: "ap-southeast-3", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ep := &endpoint.Endpoint{ DNSName: "test.example.com", SetIdentifier: "test-set", } if tt.hasRegion { ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationAWSRegion, tt.region) } gp := newGeoProximity(ep) result := gp.withAWSRegion() assert.Equal(t, tt.expectedSet, result.isSet) if tt.expectedSet { assert.NotNil(t, result.location.AWSRegion) assert.Equal(t, tt.expectedRegion, *result.location.AWSRegion) } else { assert.Nil(t, result.location.AWSRegion) } // Verify the method returns the same instance for chaining assert.Equal(t, gp, result) }) } } func TestGeoProximityWithLocalZoneGroup(t *testing.T) { tests := []struct { name string localZoneGroup string hasLocalZoneGroup bool expectedSet bool expectedLocalZoneGroup string }{ { name: "valid local zone group", localZoneGroup: "usw2-lax1-az1", hasLocalZoneGroup: true, expectedSet: true, expectedLocalZoneGroup: "usw2-lax1-az1", }, { name: "empty local zone group", localZoneGroup: "", hasLocalZoneGroup: true, expectedSet: true, expectedLocalZoneGroup: "", }, { name: "no local zone group property", localZoneGroup: "", hasLocalZoneGroup: false, expectedSet: false, expectedLocalZoneGroup: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ep := &endpoint.Endpoint{ DNSName: "test.example.com", SetIdentifier: "test-set", } if tt.hasLocalZoneGroup { ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationLocalZoneGroup, tt.localZoneGroup) } gp := newGeoProximity(ep) result := gp.withLocalZoneGroup() assert.Equal(t, tt.expectedSet, result.isSet) if tt.expectedSet { assert.NotNil(t, result.location.LocalZoneGroup) assert.Equal(t, tt.expectedLocalZoneGroup, *result.location.LocalZoneGroup) } else { assert.Nil(t, result.location.LocalZoneGroup) } // Verify method returns same instance for chaining assert.Equal(t, gp, result) }) } } func TestGeoProximityWithCoordinates(t *testing.T) { tests := []struct { name string coordinates string expectedSet bool expectedLat string expectedLong string shouldHaveCoords bool }{ { name: "valid coordinates", coordinates: "45.0,90.0", expectedSet: true, expectedLat: "45.0", expectedLong: "90.0", shouldHaveCoords: true, }, { name: "edge case min coordinates", coordinates: "-90.0,-180.0", expectedSet: true, expectedLat: "-90.0", expectedLong: "-180.0", shouldHaveCoords: true, }, { name: "edge case max coordinates", coordinates: "90.0,180.0", expectedSet: true, expectedLat: "90.0", expectedLong: "180.0", shouldHaveCoords: true, }, { name: "invalid latitude too high", coordinates: "91.0,90.0", expectedSet: false, shouldHaveCoords: false, }, { name: "invalid longitude too low", coordinates: "45.0,-181.0", expectedSet: false, shouldHaveCoords: false, }, { name: "invalid format - single value", coordinates: "45.0", expectedSet: false, shouldHaveCoords: false, }, { name: "invalid format - three values", coordinates: "45.0,90.0,10.0", expectedSet: false, shouldHaveCoords: false, }, { name: "invalid format - non-numeric", coordinates: "abc,def", expectedSet: false, shouldHaveCoords: false, }, { name: "no coordinates property", coordinates: "", expectedSet: false, shouldHaveCoords: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ep := &endpoint.Endpoint{} if tt.coordinates != "" { ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationCoordinates, tt.coordinates) } gp := newGeoProximity(ep) result := gp.withCoordinates() assert.Equal(t, tt.expectedSet, result.isSet) if tt.shouldHaveCoords { assert.NotNil(t, result.location.Coordinates) assert.Equal(t, tt.expectedLat, *result.location.Coordinates.Latitude) assert.Equal(t, tt.expectedLong, *result.location.Coordinates.Longitude) } else { assert.Nil(t, result.location.Coordinates) } }) } } func TestGeoProximityWithBias(t *testing.T) { tests := []struct { name string bias string hasBias bool expectedSet bool expectedBias int32 }{ { name: "valid positive bias", bias: "10", hasBias: true, expectedSet: true, expectedBias: 10, }, { name: "valid negative bias", bias: "-5", hasBias: true, expectedSet: true, expectedBias: -5, }, { name: "zero bias", bias: "0", hasBias: true, expectedSet: true, expectedBias: 0, }, { name: "large positive bias", bias: "99", hasBias: true, expectedSet: true, expectedBias: 99, }, { name: "large negative bias", bias: "-99", hasBias: true, expectedSet: true, expectedBias: -99, }, { name: "invalid bias - non-numeric", bias: "abc", hasBias: true, expectedSet: true, expectedBias: 0, // defaults to 0 on error }, { name: "invalid bias - float", bias: "10.5", hasBias: true, expectedSet: true, expectedBias: 0, // defaults to 0 on error }, { name: "empty bias string", bias: "", hasBias: true, expectedSet: true, expectedBias: 0, // defaults to 0 on error }, { name: "no bias property", bias: "", hasBias: false, expectedSet: false, expectedBias: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ep := &endpoint.Endpoint{ DNSName: "test.example.com", SetIdentifier: "test-set", } if tt.hasBias { ep.SetProviderSpecificProperty(providerSpecificGeoProximityLocationBias, tt.bias) } gp := newGeoProximity(ep) result := gp.withBias() assert.Equal(t, tt.expectedSet, result.isSet) if tt.expectedSet { assert.NotNil(t, result.location.Bias) assert.Equal(t, tt.expectedBias, *result.location.Bias) } else { assert.Nil(t, result.location.Bias) } // Verify method returns same instance for chaining assert.Equal(t, gp, result) }) } } func TestAWSProvider_createUpdateChanges_NewMoreThanOld(t *testing.T) { provider, _ := newAWSProvider(t, endpoint.NewDomainFilter([]string{"foo.bar."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), true, false, false, nil) oldEndpoints := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("record1.foo.bar.", endpoint.RecordTypeA, endpoint.TTL(300), "1.1.1.1"), nil, } newEndpoints := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("record1.foo.bar.", endpoint.RecordTypeA, endpoint.TTL(300), "1.1.1.1"), endpoint.NewEndpointWithTTL("record2.foo.bar.", endpoint.RecordTypeA, endpoint.TTL(300), "2.2.2.2"), endpoint.NewEndpointWithTTL("record3.foo.bar.", endpoint.RecordTypeA, endpoint.TTL(300), "3.3.3.3"), } changes := provider.createUpdateChanges(newEndpoints, oldEndpoints) // record2 should be created, record1 should be upserted var creates, upserts, deletes int for _, c := range changes { switch c.Action { case route53types.ChangeActionCreate: creates++ case route53types.ChangeActionUpsert: upserts++ case route53types.ChangeActionDelete: deletes++ } } require.Equal(t, 0, creates, "should create the extra new endpoint") require.Equal(t, 1, upserts, "should upsert the matching endpoint") require.Equal(t, 0, deletes, "should not delete anything") } func TestAWSProvider_adjustEndpointAndNewAaaaIfNeeded(t *testing.T) { tests := []struct { name string preferCNAME bool ep *endpoint.Endpoint expected *endpoint.Endpoint expectedAaaa *endpoint.Endpoint }{ // --- A / AAAA --- { name: "A record without provider specific should not change and not create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, }, expectedAaaa: nil, }, { name: "A record with alias=true should set default ttl, add evaluateTargetHealth and not create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: 600, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: defaultTTL, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", // p.evaluateTargetHealth=false in this test }, }, }, expectedAaaa: nil, }, { name: "A record with alias!=true value should remove alias and evaluateTargetHealth and not create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: 600, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "false", }, { Name: providerSpecificEvaluateTargetHealth, Value: "true", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: 600, ProviderSpecific: endpoint.ProviderSpecific{}, }, expectedAaaa: nil, }, { name: "A record with alias=true and invalid evaluateTargetHealth should normalize it to false and set default ttl", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: 600, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "invalid", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: defaultTTL, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", }, }, }, expectedAaaa: nil, }, { name: "AAAA record with alias=true should behave like A record", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}, RecordTTL: 600, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}, RecordTTL: defaultTTL, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", }, }, }, expectedAaaa: nil, }, // --- CNAME --- { name: "CNAME record with alias=false should keep alias=false, remove evaluateTargetHealth and not create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, RecordTTL: 600, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "false", }, { Name: providerSpecificEvaluateTargetHealth, Value: "true", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, RecordTTL: 600, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "false", }, }, }, expectedAaaa: nil, }, { name: "CNAME record with invalid alias value should normalize to alias=false and not create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, RecordTTL: 600, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "invalid", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, RecordTTL: 600, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "false", }, }, }, expectedAaaa: nil, }, { name: "CNAME record with alias=true should set default ttl, add evaluateTargetHealth and create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, RecordTTL: 600, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, RecordTTL: defaultTTL, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", // p.evaluateTargetHealth=false in this test }, }, }, expectedAaaa: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeAAAA, RecordTTL: defaultTTL, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", // p.evaluateTargetHealth=false in this test }, }, }, }, { name: "CNAME record with alias=true and evaluateTargetHealth=true should keep evaluateTargetHealth and create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, RecordTTL: 600, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "true", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, RecordTTL: defaultTTL, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "true", }, }, }, expectedAaaa: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeAAAA, RecordTTL: defaultTTL, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "true", }, }, }, }, { name: "CNAME record with alias=true and invalid evaluateTargetHealth should normalize it to false and create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, RecordTTL: 600, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "invalid", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, RecordTTL: defaultTTL, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", }, }, }, expectedAaaa: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeAAAA, RecordTTL: defaultTTL, Targets: endpoint.Targets{"target.foo.bar."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", }, }, }, }, { name: "CNAME without alias to ELB target should enable alias and create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com"}, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", }, }, }, expectedAaaa: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, { Name: providerSpecificEvaluateTargetHealth, Value: "false", }, }, }, }, { name: "CNAME with preferCNAME=true should set alias=false and not create AAAA even for ELB target", preferCNAME: true, ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com."}, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"test-123.us-east-1.elb.amazonaws.com."}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "false", }, }, }, expectedAaaa: nil, }, // --- MX / other records --- { name: "MX record without provider specific should not change and not create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"10 mail.example.com."}, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"10 mail.example.com."}, }, expectedAaaa: nil, }, // TODO: fix For records other than A, AAAA, and CNAME, if an alias record is set, the alias record processing is not performed. This will be fixed in another PR. { name: "MX record with alias=true should remove alias and set default ttl, add evaluateTargetHealth and not create AAAA", ep: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"10 mail.example.com."}, RecordTTL: 600, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificAlias, Value: "true", }, }, }, expected: &endpoint.Endpoint{ DNSName: "test.foo.bar.", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"10 mail.example.com."}, RecordTTL: defaultTTL, ProviderSpecific: endpoint.ProviderSpecific{ { Name: providerSpecificEvaluateTargetHealth, Value: "false", }, }, }, expectedAaaa: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() p, _ := newAWSProvider( t, endpoint.NewDomainFilter([]string{"foo.bar."}), provider.NewZoneIDFilter([]string{}), provider.NewZoneTypeFilter(""), false, tt.preferCNAME, false, nil, ) aaaa := p.adjustEndpointAndNewAaaaIfNeeded(tt.ep) assert.True(t, testutils.SameEndpoint(tt.ep, tt.expected), "actual and expected endpoints don't match. %+v:%+v", tt.ep, tt.expected) assert.True(t, testutils.SameEndpoint(aaaa, tt.expectedAaaa), "actual and expected AAAA endpoints don't match. %+v:%+v", aaaa, tt.expectedAaaa) }) } } ================================================ FILE: provider/aws/aws_utils_test.go ================================================ /* 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 aws import ( "context" "os" "testing" "time" "github.com/aws/aws-sdk-go-v2/service/route53" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/goccy/go-yaml" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/provider/blueprint" ) type HostedZones struct { Zones []*HostedZone `yaml:"zones"` } type HostedZone struct { Name string ID string Tags []route53types.Tag `yaml:"tags"` } var _ Route53API = &Route53APIFixtureStub{} type Route53APIFixtureStub struct { zones map[string]*route53types.HostedZone zoneTags map[string][]route53types.Tag calls map[string]int } func providerFilters(client *Route53APIFixtureStub, options ...func(awsProvider *AWSProvider)) *AWSProvider { p := &AWSProvider{ clients: map[string]Route53API{defaultAWSProfile: client}, evaluateTargetHealth: false, dryRun: false, domainFilter: &endpoint.DomainFilter{}, zoneIDFilter: provider.NewZoneIDFilter([]string{}), zoneTypeFilter: provider.NewZoneTypeFilter(""), zoneTagFilter: provider.NewZoneTagFilter([]string{}), zonesCache: blueprint.NewZoneCache[map[string]*profiledZone](1 * time.Second), } for _, o := range options { o(p) } return p } func WithDomainFilters(filters ...string) func(awsProvider *AWSProvider) { return func(awsProvider *AWSProvider) { awsProvider.domainFilter = endpoint.NewDomainFilter(filters) } } func WithZoneIDFilters(filters ...string) func(awsProvider *AWSProvider) { return func(awsProvider *AWSProvider) { awsProvider.zoneIDFilter = provider.NewZoneIDFilter(filters) } } func WithZoneTagFilters(filters []string) func(awsProvider *AWSProvider) { return func(awsProvider *AWSProvider) { awsProvider.zoneTagFilter = provider.NewZoneTagFilter(filters) } } func NewRoute53APIFixtureStub(zones *HostedZones) *Route53APIFixtureStub { route53Zones := make(map[string]*route53types.HostedZone) zoneTags := make(map[string][]route53types.Tag) for _, zone := range zones.Zones { route53Zones[zone.ID] = &route53types.HostedZone{ Id: &zone.ID, Name: &zone.Name, } zoneTags[cleanZoneID(zone.ID)] = zone.Tags } return &Route53APIFixtureStub{ zones: route53Zones, zoneTags: zoneTags, calls: make(map[string]int), } } func (r Route53APIFixtureStub) ListResourceRecordSets(_ context.Context, _ *route53.ListResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ListResourceRecordSetsOutput, error) { // TODO implement me panic("implement me") } func (r Route53APIFixtureStub) ChangeResourceRecordSets(_ context.Context, _ *route53.ChangeResourceRecordSetsInput, _ ...func(options *route53.Options)) (*route53.ChangeResourceRecordSetsOutput, error) { // TODO implement me panic("implement me") } func (r Route53APIFixtureStub) CreateHostedZone(_ context.Context, _ *route53.CreateHostedZoneInput, _ ...func(*route53.Options)) (*route53.CreateHostedZoneOutput, error) { // TODO implement me panic("implement me") } func (r Route53APIFixtureStub) ListHostedZones(_ context.Context, _ *route53.ListHostedZonesInput, _ ...func(options *route53.Options)) (*route53.ListHostedZonesOutput, error) { r.calls["listhostedzones"]++ output := &route53.ListHostedZonesOutput{} for _, zone := range r.zones { output.HostedZones = append(output.HostedZones, *zone) } return output, nil } func (r Route53APIFixtureStub) ListTagsForResources(_ context.Context, input *route53.ListTagsForResourcesInput, _ ...func(options *route53.Options)) (*route53.ListTagsForResourcesOutput, error) { r.calls["listtagsforresource"]++ var sets []route53types.ResourceTagSet for _, el := range input.ResourceIds { if r.zoneTags[el] != nil { sets = append(sets, route53types.ResourceTagSet{ ResourceId: &el, ResourceType: route53types.TagResourceTypeHostedzone, Tags: r.zoneTags[el], }) } } return &route53.ListTagsForResourcesOutput{ResourceTagSets: sets}, nil } func unmarshalZonesFixture(obj any, t *testing.T) { t.Helper() path, _ := os.Getwd() file, err := os.Open(path + "/fixtures/160-plus-zones.yaml") assert.NoError(t, err) defer file.Close() dec := yaml.NewDecoder(file) err = dec.Decode(obj) assert.NoError(t, err) } ================================================ FILE: provider/aws/config.go ================================================ /* Copyright 2023 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 aws import ( "context" "fmt" awsv2 "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/retry" "github.com/aws/aws-sdk-go-v2/config" stscredsv2 "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) // AWSSessionConfig contains configuration to create a new AWS provider. type AWSSessionConfig struct { AssumeRole string AssumeRoleExternalID string APIRetries int Profile string } func CreateDefaultV2Config(cfg *externaldns.Config) awsv2.Config { result, err := newV2Config( AWSSessionConfig{ AssumeRole: cfg.AWSAssumeRole, AssumeRoleExternalID: cfg.AWSAssumeRoleExternalID, APIRetries: cfg.AWSAPIRetries, }, ) if err != nil { logrus.Fatal(err) } return result } func CreateV2Configs(cfg *externaldns.Config) map[string]awsv2.Config { result := make(map[string]awsv2.Config) if len(cfg.AWSProfiles) == 0 || (len(cfg.AWSProfiles) == 1 && cfg.AWSProfiles[0] == "") { cfg := CreateDefaultV2Config(cfg) result[defaultAWSProfile] = cfg } else { for _, profile := range cfg.AWSProfiles { cfg, err := newV2Config( AWSSessionConfig{ AssumeRole: cfg.AWSAssumeRole, AssumeRoleExternalID: cfg.AWSAssumeRoleExternalID, APIRetries: cfg.AWSAPIRetries, Profile: profile, }, ) if err != nil { logrus.Fatal(err) } result[profile] = cfg } } return result } func newV2Config(awsConfig AWSSessionConfig) (awsv2.Config, error) { defaultOpts := []func(*config.LoadOptions) error{ config.WithRetryer(func() awsv2.Retryer { return retry.AddWithMaxAttempts(retry.NewStandard(), awsConfig.APIRetries) }), config.WithSharedConfigProfile(awsConfig.Profile), config.WithAPIOptions(GetInstrumentationMiddlewares()), } cfg, err := config.LoadDefaultConfig(context.Background(), defaultOpts...) if err != nil { return awsv2.Config{}, fmt.Errorf("instantiating AWS config: %w", err) } if awsConfig.AssumeRole != "" { stsSvc := sts.NewFromConfig(cfg) var assumeRoleOpts []func(*stscredsv2.AssumeRoleOptions) if awsConfig.AssumeRoleExternalID != "" { logrus.Infof("Assuming role %s with external id", awsConfig.AssumeRole) logrus.Debugf("External id: %s", awsConfig.AssumeRoleExternalID) assumeRoleOpts = []func(*stscredsv2.AssumeRoleOptions){ func(opts *stscredsv2.AssumeRoleOptions) { opts.ExternalID = &awsConfig.AssumeRoleExternalID }, } } else { logrus.Infof("Assuming role: %s", awsConfig.AssumeRole) } creds := stscredsv2.NewAssumeRoleProvider(stsSvc, awsConfig.AssumeRole, assumeRoleOpts...) cfg.Credentials = awsv2.NewCredentialsCache(creds) } return cfg, nil } ================================================ FILE: provider/aws/config_test.go ================================================ /* Copyright 2023 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 aws import ( "fmt" "os" "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) func Test_newV2Config(t *testing.T) { testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_REGION": "us-east-1", "AWS_EC2_METADATA_DISABLED": "true", }) t.Run("should use profile from credentials file", func(t *testing.T) { // setup credsFile, err := prepareCredentialsFile(t) defer os.Remove(credsFile.Name()) require.NoError(t, err) testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_SHARED_CREDENTIALS_FILE": credsFile.Name(), }) // when cfg, err := newV2Config(AWSSessionConfig{Profile: "profile2"}) require.NoError(t, err) creds, err := cfg.Credentials.Retrieve(t.Context()) // then assert.NoError(t, err) assert.Equal(t, "AKID2345", creds.AccessKeyID) assert.Equal(t, "SECRET2", creds.SecretAccessKey) }) t.Run("should respect env variables without profile", func(t *testing.T) { // setup testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", "AWS_SECRET_ACCESS_KEY": "topsecret", }) // when cfg, err := newV2Config(AWSSessionConfig{}) require.NoError(t, err) creds, err := cfg.Credentials.Retrieve(t.Context()) // then assert.NoError(t, err) assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", creds.AccessKeyID) assert.Equal(t, "topsecret", creds.SecretAccessKey) }) t.Run("should not error when AWS_CA_BUNDLE set", func(t *testing.T) { // setup testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_CA_BUNDLE": "../../internal/testresources/ca.pem", }) // when _, err := newV2Config(AWSSessionConfig{}) require.NoError(t, err) // then assert.NoError(t, err) }) t.Run("should configure assume role credentials", func(t *testing.T) { // setup testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", "AWS_SECRET_ACCESS_KEY": "topsecret", }) // when cfg, err := newV2Config(AWSSessionConfig{ AssumeRole: "arn:aws:iam::123456789012:role/example", AssumeRoleExternalID: "external-id", }) // then require.NoError(t, err) require.NotNil(t, cfg.Credentials) assert.Contains(t, fmt.Sprintf("%T", cfg.Credentials), "CredentialsCache") }) t.Run("should log assume role without external ID", func(t *testing.T) { // setup testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", "AWS_SECRET_ACCESS_KEY": "topsecret", }) hook := logtest.LogsUnderTestWithLogLevel(logrus.InfoLevel, t) defer hook.Reset() // when _, err := newV2Config(AWSSessionConfig{ AssumeRole: "arn:aws:iam::123456789012:role/example", }) // then require.NoError(t, err) logtest.TestHelperLogContainsWithLogLevel( "Assuming role: arn:aws:iam::123456789012:role/example", logrus.InfoLevel, hook, t, ) }) t.Run("returns error when config cannot be loaded", func(t *testing.T) { // setup testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_SHARED_CREDENTIALS_FILE": "missing-ca.pem", }) // when _, err := newV2Config(AWSSessionConfig{Profile: "profile1"}) // then require.Error(t, err) assert.Contains(t, err.Error(), "instantiating AWS config") }) } func prepareCredentialsFile(t *testing.T) (*os.File, error) { credsFile, err := os.CreateTemp(t.TempDir(), "aws-*.creds") require.NoError(t, err) _, err = credsFile.WriteString("[profile1]\naws_access_key_id=AKID1234\naws_secret_access_key=SECRET1\n\n[profile2]\naws_access_key_id=AKID2345\naws_secret_access_key=SECRET2\n") require.NoError(t, err) err = credsFile.Close() require.NoError(t, err) return credsFile, err } func TestCreateV2Configs(t *testing.T) { testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_REGION": "us-east-1", "AWS_EC2_METADATA_DISABLED": "true", }) t.Run("returns default profile when none configured", func(t *testing.T) { // setup testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", "AWS_SECRET_ACCESS_KEY": "topsecret", }) cfg := &externaldns.Config{ AWSAPIRetries: 3, } // when configs := CreateV2Configs(cfg) // then require.Len(t, configs, 1) _, ok := configs[defaultAWSProfile] assert.True(t, ok) }) t.Run("returns profile configs when configured", func(t *testing.T) { // setup credsFile, err := prepareCredentialsFile(t) defer os.Remove(credsFile.Name()) require.NoError(t, err) testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_SHARED_CREDENTIALS_FILE": credsFile.Name(), }) cfg := &externaldns.Config{ AWSProfiles: []string{"profile1", "profile2"}, AWSAPIRetries: 2, } // when configs := CreateV2Configs(cfg) // then require.Len(t, configs, 2) creds, err := configs["profile1"].Credentials.Retrieve(t.Context()) require.NoError(t, err) assert.Equal(t, "AKID1234", creds.AccessKeyID) assert.Equal(t, "SECRET1", creds.SecretAccessKey) creds, err = configs["profile2"].Credentials.Retrieve(t.Context()) require.NoError(t, err) assert.Equal(t, "AKID2345", creds.AccessKeyID) assert.Equal(t, "SECRET2", creds.SecretAccessKey) }) } func TestCreateConfigFatalOnError(t *testing.T) { testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_REGION": "us-east-1", "AWS_EC2_METADATA_DISABLED": "true", }) t.Run("CreateDefaultV2Config exits on load error", func(t *testing.T) { testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_PROFILE": "profile1", "AWS_SHARED_CREDENTIALS_FILE": "missing-ca.pem", }) exitCode := 0 _ = logtest.TestHelperWithLogExitFunc(func(code int) { exitCode = code panic("exit") }) assert.Panics(t, func() { CreateDefaultV2Config(&externaldns.Config{}) }) assert.Equal(t, 1, exitCode) }) t.Run("CreateV2Configs exits on load error", func(t *testing.T) { testutils.TestHelperEnvSetter(t, map[string]string{ "AWS_SHARED_CREDENTIALS_FILE": "missing-ca.pem", }) exitCode := 0 _ = logtest.TestHelperWithLogExitFunc(func(code int) { exitCode = code panic("exit") }) assert.Panics(t, func() { CreateV2Configs(&externaldns.Config{AWSProfiles: []string{"profile1"}}) }) assert.Equal(t, 1, exitCode) }) } ================================================ FILE: provider/aws/fixtures/160-plus-zones.yaml ================================================ # AWS zones fixtures with tags # number of zones 160+ (root domain + subdomains) # root domain ex.com # subdomains examples in the form of # - x1.ex.com # - ........ # - x7.x6.x5.x4.x3.x2.x1.ex.com zones: - name: ex.com. id: /hostedzone/Z10242883PKPS38KA4S6C tags: - key: owner value: ext-dns - key: level value: root - key: managed value: terraform - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: x7.x6.x5.x4.x3.x2.x1.ex.com. id: /hostedzone/Z1032002B96QH7HFX83T tags: - key: owner value: ext-dns - key: parentdomain value: x6.x5.x4.x3.x2.x1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: x7.x6.x5.x4.x3.x2.x1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: u7.u6.u5.u4.u3.u2.u1.ex.com. id: /hostedzone/Z10320232590ZA96YS1UU tags: - key: owner value: ext-dns - key: parentdomain value: u6.u5.u4.u3.u2.u1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: u7.u6.u5.u4.u3.u2.u1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: a7.a6.a5.a4.a3.a2.a1.ex.com. id: /hostedzone/Z10315369KW0URNJU6RK tags: - key: owner value: ext-dns - key: parentdomain value: a6.a5.a4.a3.a2.a1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: a7.a6.a5.a4.a3.a2.a1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: j7.j6.j5.j4.j3.j2.j1.ex.com. id: /hostedzone/Z10295763LSQ170JCTR78 tags: - key: owner value: ext-dns - key: parentdomain value: j6.j5.j4.j3.j2.j1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: j7.j6.j5.j4.j3.j2.j1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: c7.c6.c5.c4.c3.c2.c1.ex.com. id: /hostedzone/Z10288493DKATL8232JNZ tags: - key: owner value: ext-dns - key: parentdomain value: c6.c5.c4.c3.c2.c1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: c7.c6.c5.c4.c3.c2.c1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: f7.f6.f5.f4.f3.f2.f1.ex.com. id: /hostedzone/Z102884820EE3LQYU9WOM tags: - key: owner value: ext-dns - key: parentdomain value: f6.f5.f4.f3.f2.f1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: f7.f6.f5.f4.f3.f2.f1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: w7.w6.w5.w4.w3.w2.w1.ex.com. id: /hostedzone/Z10283413MVB9WV61S7OA tags: - key: owner value: ext-dns - key: parentdomain value: w6.w5.w4.w3.w2.w1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: w7.w6.w5.w4.w3.w2.w1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: r1.ex.com. id: /hostedzone/Z1016808BHW7INWWYYIF tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: r1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: h1.ex.com. id: /hostedzone/Z1016090E4ZMPH0OUE8Z tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: h1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: t1.ex.com. id: /hostedzone/Z10161971RT6SUM2O9Q5E tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: t1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: o1.ex.com. id: /hostedzone/Z10159761394AY0UJXGVW tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: o1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: g1.ex.com. id: /hostedzone/Z10160182ROTV7D06VGRH tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: g1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: l2.l1.ex.com. id: /hostedzone/Z10238911BEICMQG6K7N4 tags: - key: owner value: ext-dns - key: parentdomain value: l1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: l2.l1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: d1.ex.com. id: /hostedzone/Z10238042ATQ2R880EA30 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: d1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: g2.g1.ex.com. id: /hostedzone/Z102355127WZ6HZSCS1UQ tags: - key: owner value: ext-dns - key: parentdomain value: g1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: g2.g1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: k1.ex.com. id: /hostedzone/Z10228862S8D8AWIBRPX5 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: k1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: z2.z1.ex.com. id: /hostedzone/Z1022753YQV9L66OQKKG tags: - key: owner value: ext-dns - key: parentdomain value: z1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: z2.z1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: n1.ex.com. id: /hostedzone/Z10222872GS2T2IW5YE39 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: n1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: p3.p2.p1.ex.com. id: /hostedzone/Z10392163D8GY90CD0H8 tags: - key: owner value: ext-dns - key: parentdomain value: p2.p1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: p3.p2.p1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: z3.z2.z1.ex.com. id: /hostedzone/Z10385543080IK4J0F0TN tags: - key: owner value: ext-dns - key: parentdomain value: z2.z1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: z3.z2.z1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: m3.m2.m1.ex.com. id: /hostedzone/Z10382283MVOVGXANQEYL tags: - key: owner value: ext-dns - key: parentdomain value: m2.m1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: m3.m2.m1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: r4.r3.r2.r1.ex.com. id: /hostedzone/Z1038243J4I23XS7OSKF tags: - key: owner value: ext-dns - key: parentdomain value: r3.r2.r1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: r4.r3.r2.r1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: k3.k2.k1.ex.com. id: /hostedzone/Z103808619D40WT1DR49J tags: - key: owner value: ext-dns - key: parentdomain value: k2.k1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: k3.k2.k1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: p4.p3.p2.p1.ex.com. id: /hostedzone/Z1038033327YHLUQQI3X2 tags: - key: owner value: ext-dns - key: parentdomain value: p3.p2.p1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: p4.p3.p2.p1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: m5.m4.m3.m2.m1.ex.com. id: /hostedzone/Z0533089I4KYU2CI4QI1 tags: - key: owner value: ext-dns - key: parentdomain value: m4.m3.m2.m1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: m5.m4.m3.m2.m1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: c5.c4.c3.c2.c1.ex.com. id: /hostedzone/Z05330532TU88KT3RR8DV tags: - key: owner value: ext-dns - key: parentdomain value: c4.c3.c2.c1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: c5.c4.c3.c2.c1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: r5.r4.r3.r2.r1.ex.com. id: /hostedzone/Z05330482Y4BD73GXQL9R tags: - key: owner value: ext-dns - key: parentdomain value: r4.r3.r2.r1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: r5.r4.r3.r2.r1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: g5.g4.g3.g2.g1.ex.com. id: /hostedzone/Z0532558273KTRBR8UW6J tags: - key: owner value: ext-dns - key: parentdomain value: g4.g3.g2.g1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: g5.g4.g3.g2.g1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: r6.r5.r4.r3.r2.r1.ex.com. id: /hostedzone/Z054065934ZVPHZZKEPH4 tags: - key: owner value: ext-dns - key: parentdomain value: r5.r4.r3.r2.r1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: r6.r5.r4.r3.r2.r1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: f5.f4.f3.f2.f1.ex.com. id: /hostedzone/Z053991036YAFX9ZO6WWP tags: - key: owner value: ext-dns - key: parentdomain value: f4.f3.f2.f1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: f5.f4.f3.f2.f1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: a5.a4.a3.a2.a1.ex.com. id: /hostedzone/Z05399152X3HLIXV18OFH tags: - key: owner value: ext-dns - key: parentdomain value: a4.a3.a2.a1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: a5.a4.a3.a2.a1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: g6.g5.g4.g3.g2.g1.ex.com. id: /hostedzone/Z0539521253N0CZENGVYT tags: - key: owner value: ext-dns - key: parentdomain value: g5.g4.g3.g2.g1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: g6.g5.g4.g3.g2.g1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: h5.h4.h3.h2.h1.ex.com. id: /hostedzone/Z0539437KPBCRSU5OQS2 tags: - key: owner value: ext-dns - key: parentdomain value: h4.h3.h2.h1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: h5.h4.h3.h2.h1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: z6.z5.z4.z3.z2.z1.ex.com. id: /hostedzone/Z05393465GOC3P191Z3M tags: - key: owner value: ext-dns - key: parentdomain value: z5.z4.z3.z2.z1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: z6.z5.z4.z3.z2.z1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: x6.x5.x4.x3.x2.x1.ex.com. id: /hostedzone/Z05392373JKKXLCN3FNI4 tags: - key: owner value: ext-dns - key: parentdomain value: x5.x4.x3.x2.x1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: x6.x5.x4.x3.x2.x1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: j5.j4.j3.j2.j1.ex.com. id: /hostedzone/Z0539046IFXW5PM8YN1H tags: - key: owner value: ext-dns - key: parentdomain value: j4.j3.j2.j1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: j5.j4.j3.j2.j1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: d6.d5.d4.d3.d2.d1.ex.com. id: /hostedzone/Z053882914TO782DFG8CG tags: - key: owner value: ext-dns - key: parentdomain value: d5.d4.d3.d2.d1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: d6.d5.d4.d3.d2.d1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: z5.z4.z3.z2.z1.ex.com. id: /hostedzone/Z0538661Y3HB6MCJOE82 tags: - key: owner value: ext-dns - key: parentdomain value: z4.z3.z2.z1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: z5.z4.z3.z2.z1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: k5.k4.k3.k2.k1.ex.com. id: /hostedzone/Z05385574XIO6WFJ8LQ1 tags: - key: owner value: ext-dns - key: parentdomain value: k4.k3.k2.k1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: k5.k4.k3.k2.k1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: i5.i4.i3.i2.i1.ex.com. id: /hostedzone/Z053855215ZX8GW2J5KBG tags: - key: owner value: ext-dns - key: parentdomain value: i4.i3.i2.i1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: i5.i4.i3.i2.i1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: o5.o4.o3.o2.o1.ex.com. id: /hostedzone/Z0538559IWTO974D6T3D tags: - key: owner value: ext-dns - key: parentdomain value: o4.o3.o2.o1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: o5.o4.o3.o2.o1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: o6.o5.o4.o3.o2.o1.ex.com. id: /hostedzone/Z05384951BX94HIDSNC29 tags: - key: owner value: ext-dns - key: parentdomain value: o5.o4.o3.o2.o1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: o6.o5.o4.o3.o2.o1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: w6.w5.w4.w3.w2.w1.ex.com. id: /hostedzone/Z053846613UOJETPXKV9G tags: - key: owner value: ext-dns - key: parentdomain value: w5.w4.w3.w2.w1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: w6.w5.w4.w3.w2.w1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: b6.b5.b4.b3.b2.b1.ex.com. id: /hostedzone/Z05380942D45I29YOVUJ1 tags: - key: owner value: ext-dns - key: parentdomain value: b5.b4.b3.b2.b1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: b6.b5.b4.b3.b2.b1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: p6.p5.p4.p3.p2.p1.ex.com. id: /hostedzone/Z05376071AXQ7LGK8Y7FO tags: - key: owner value: ext-dns - key: parentdomain value: p5.p4.p3.p2.p1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: p6.p5.p4.p3.p2.p1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: i6.i5.i4.i3.i2.i1.ex.com. id: /hostedzone/Z05375472NGNYPN630LTO tags: - key: owner value: ext-dns - key: parentdomain value: i5.i4.i3.i2.i1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: i6.i5.i4.i3.i2.i1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: k6.k5.k4.k3.k2.k1.ex.com. id: /hostedzone/Z05374771LUBA1Z2GUIT6 tags: - key: owner value: ext-dns - key: parentdomain value: k5.k4.k3.k2.k1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: k6.k5.k4.k3.k2.k1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: h6.h5.h4.h3.h2.h1.ex.com. id: /hostedzone/Z0537068TAWOCOL8HJ61 tags: - key: owner value: ext-dns - key: parentdomain value: h5.h4.h3.h2.h1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: h6.h5.h4.h3.h2.h1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: s7.s6.s5.s4.s3.s2.s1.ex.com. id: /hostedzone/Z072010925XG7D9WXB3H tags: - key: owner value: ext-dns - key: parentdomain value: s6.s5.s4.s3.s2.s1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: s7.s6.s5.s4.s3.s2.s1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: y7.y6.y5.y4.y3.y2.y1.ex.com. id: /hostedzone/Z071952623W901PGCEAA1 tags: - key: owner value: ext-dns - key: parentdomain value: y6.y5.y4.y3.y2.y1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: y7.y6.y5.y4.y3.y2.y1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: k7.k6.k5.k4.k3.k2.k1.ex.com. id: /hostedzone/Z0719164E82YWUXVQD4F tags: - key: owner value: ext-dns - key: parentdomain value: k6.k5.k4.k3.k2.k1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: k7.k6.k5.k4.k3.k2.k1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: c1.ex.com. id: /hostedzone/Z07076132LTJ3XGH5CJWY tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: c1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: z1.ex.com. id: /hostedzone/Z070741827T4NNW17A6N9 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: z1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: i2.i1.ex.com. id: /hostedzone/Z0706863ZTD2HUTU10N0 tags: - key: owner value: ext-dns - key: parentdomain value: i1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: i2.i1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: x2.x1.ex.com. id: /hostedzone/Z0705382P6ROONRM2CKR tags: - key: owner value: ext-dns - key: parentdomain value: x1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: x2.x1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: p1.ex.com. id: /hostedzone/Z0705253DHCTMDZ48Q4V tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: p1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: s1.ex.com. id: /hostedzone/Z070526324O7V32HB2GP4 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: s1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: n3.n2.n1.ex.com. id: /hostedzone/Z072628929FO3LXWQVOY7 tags: - key: owner value: ext-dns - key: parentdomain value: n2.n1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: n3.n2.n1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: r3.r2.r1.ex.com. id: /hostedzone/Z07259612MWHI9NFRS53E tags: - key: owner value: ext-dns - key: parentdomain value: r2.r1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: r3.r2.r1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: j3.j2.j1.ex.com. id: /hostedzone/Z072595530FWB47CDV2WC tags: - key: owner value: ext-dns - key: parentdomain value: j2.j1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: j3.j2.j1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: d3.d2.d1.ex.com. id: /hostedzone/Z0725572346MYVZ3KGNUH tags: - key: owner value: ext-dns - key: parentdomain value: d2.d1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: d3.d2.d1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: o3.o2.o1.ex.com. id: /hostedzone/Z072558318OW0Y4DNPN66 tags: - key: owner value: ext-dns - key: parentdomain value: o2.o1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: o3.o2.o1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: x3.x2.x1.ex.com. id: /hostedzone/Z0725404124TWY26MTQXT tags: - key: owner value: ext-dns - key: parentdomain value: x2.x1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: x3.x2.x1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: l3.l2.l1.ex.com. id: /hostedzone/Z07255931Q3MVC1T9FIX1 tags: - key: owner value: ext-dns - key: parentdomain value: l2.l1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: l3.l2.l1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: t4.t3.t2.t1.ex.com. id: /hostedzone/Z0725300TUYR9MM1NP9T tags: - key: owner value: ext-dns - key: parentdomain value: t3.t2.t1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: t4.t3.t2.t1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: i4.i3.i2.i1.ex.com. id: /hostedzone/Z07252442H3FRQ6T9REYR tags: - key: owner value: ext-dns - key: parentdomain value: i3.i2.i1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: i4.i3.i2.i1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: g3.g2.g1.ex.com. id: /hostedzone/Z072522110PO1T9F6EOIH tags: - key: owner value: ext-dns - key: parentdomain value: g2.g1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: g3.g2.g1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: y3.y2.y1.ex.com. id: /hostedzone/Z074908589R64YFZTTRF tags: - key: owner value: ext-dns - key: parentdomain value: y2.y1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: y3.y2.y1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: w4.w3.w2.w1.ex.com. id: /hostedzone/Z07491213MWEKC6MH5BEX tags: - key: owner value: ext-dns - key: parentdomain value: w3.w2.w1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: w4.w3.w2.w1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: e4.e3.e2.e1.ex.com. id: /hostedzone/Z0748318XZ5SAQVUUDHE tags: - key: owner value: ext-dns - key: parentdomain value: e3.e2.e1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: e4.e3.e2.e1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: a4.a3.a2.a1.ex.com. id: /hostedzone/Z07482493MHOG25MXMYQW tags: - key: owner value: ext-dns - key: parentdomain value: a3.a2.a1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: a4.a3.a2.a1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: g4.g3.g2.g1.ex.com. id: /hostedzone/Z07473562MT9TYWVVE38G tags: - key: owner value: ext-dns - key: parentdomain value: g3.g2.g1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: g4.g3.g2.g1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: d4.d3.d2.d1.ex.com. id: /hostedzone/Z0747355363XLS6BWS4JP tags: - key: owner value: ext-dns - key: parentdomain value: d3.d2.d1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: d4.d3.d2.d1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: u4.u3.u2.u1.ex.com. id: /hostedzone/Z07473491EXNK832Z1KY2 tags: - key: owner value: ext-dns - key: parentdomain value: u3.u2.u1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: u4.u3.u2.u1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: e5.e4.e3.e2.e1.ex.com. id: /hostedzone/Z07516762P24S9BSAPW20 tags: - key: owner value: ext-dns - key: parentdomain value: e4.e3.e2.e1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: e5.e4.e3.e2.e1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: y5.y4.y3.y2.y1.ex.com. id: /hostedzone/Z07510071JNCBZN4PU9PU tags: - key: owner value: ext-dns - key: parentdomain value: y4.y3.y2.y1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: y5.y4.y3.y2.y1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: x5.x4.x3.x2.x1.ex.com. id: /hostedzone/Z0750455O2P676BQCQ36 tags: - key: owner value: ext-dns - key: parentdomain value: x4.x3.x2.x1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: x5.x4.x3.x2.x1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: s5.s4.s3.s2.s1.ex.com. id: /hostedzone/Z07503442IXQWYXD3NA2P tags: - key: owner value: ext-dns - key: parentdomain value: s4.s3.s2.s1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: s5.s4.s3.s2.s1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: y6.y5.y4.y3.y2.y1.ex.com. id: /hostedzone/Z07499781IH1IWL681HZA tags: - key: owner value: ext-dns - key: parentdomain value: y5.y4.y3.y2.y1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: y6.y5.y4.y3.y2.y1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: s6.s5.s4.s3.s2.s1.ex.com. id: /hostedzone/Z074127711E3PB3VWCVR8 tags: - key: owner value: ext-dns - key: parentdomain value: s5.s4.s3.s2.s1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: s6.s5.s4.s3.s2.s1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: l6.l5.l4.l3.l2.l1.ex.com. id: /hostedzone/Z07407861HUM7TWLBEVHW tags: - key: owner value: ext-dns - key: parentdomain value: l5.l4.l3.l2.l1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: l6.l5.l4.l3.l2.l1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: j6.j5.j4.j3.j2.j1.ex.com. id: /hostedzone/Z07407492KRCV3E1Q3TTH tags: - key: owner value: ext-dns - key: parentdomain value: j5.j4.j3.j2.j1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: j6.j5.j4.j3.j2.j1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: z7.z6.z5.z4.z3.z2.z1.ex.com. id: /hostedzone/Z05977813GQTSJK0XA6CR tags: - key: owner value: ext-dns - key: parentdomain value: z6.z5.z4.z3.z2.z1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: z7.z6.z5.z4.z3.z2.z1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: p7.p6.p5.p4.p3.p2.p1.ex.com. id: /hostedzone/Z05976812XQ4FYDX0EA1M tags: - key: owner value: ext-dns - key: parentdomain value: p6.p5.p4.p3.p2.p1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: p7.p6.p5.p4.p3.p2.p1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: r7.r6.r5.r4.r3.r2.r1.ex.com. id: /hostedzone/Z059725536VFD0SUK6COX tags: - key: owner value: ext-dns - key: parentdomain value: r6.r5.r4.r3.r2.r1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: r7.r6.r5.r4.r3.r2.r1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: n7.n6.n5.n4.n3.n2.n1.ex.com. id: /hostedzone/Z05968613E7Z7DOMT8XQY tags: - key: owner value: ext-dns - key: parentdomain value: n6.n5.n4.n3.n2.n1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: n7.n6.n5.n4.n3.n2.n1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: j1.ex.com. id: /hostedzone/Z061728615D0VKL6DXRPH tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: j1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: y1.ex.com. id: /hostedzone/Z06172299A0X24MKXTOD tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: y1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: p2.p1.ex.com. id: /hostedzone/Z06167006NZ1D5IRNRYA tags: - key: owner value: ext-dns - key: parentdomain value: p1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: p2.p1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: f1.ex.com. id: /hostedzone/Z06160862E2ELNT29RQL3 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: f1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: l1.ex.com. id: /hostedzone/Z06158931J4KH25JG225F tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: l1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: a1.ex.com. id: /hostedzone/Z06155043AVN8RVC88TYY tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: a1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: u1.ex.com. id: /hostedzone/Z061540412CK4L8XI4BB6 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: u1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: c2.c1.ex.com. id: /hostedzone/Z06150423UYFKAE29W1SW tags: - key: owner value: ext-dns - key: parentdomain value: c1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: c2.c1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: m2.m1.ex.com. id: /hostedzone/Z06152622MZNX328M11VY tags: - key: owner value: ext-dns - key: parentdomain value: m1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: m2.m1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: o2.o1.ex.com. id: /hostedzone/Z06151611YG2J9D7Y9URS tags: - key: owner value: ext-dns - key: parentdomain value: o1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: o2.o1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: w1.ex.com. id: /hostedzone/Z06144631U9WHJGPNE0F6 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: w1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: b2.b1.ex.com. id: /hostedzone/Z0621921Q39DAS25KW2F tags: - key: owner value: ext-dns - key: parentdomain value: b1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: b2.b1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: k2.k1.ex.com. id: /hostedzone/Z06214591V9ROEHRT4AJR tags: - key: owner value: ext-dns - key: parentdomain value: k1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: k2.k1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: r2.r1.ex.com. id: /hostedzone/Z062145823F9HOXQ8AQTT tags: - key: owner value: ext-dns - key: parentdomain value: r1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: r2.r1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: a2.a1.ex.com. id: /hostedzone/Z062114713UUBAQBGL50S tags: - key: owner value: ext-dns - key: parentdomain value: a1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: a2.a1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: u2.u1.ex.com. id: /hostedzone/Z062114626Y9F53JWLIF3 tags: - key: owner value: ext-dns - key: parentdomain value: u1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: u2.u1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: j2.j1.ex.com. id: /hostedzone/Z06208221KB9XPMJOH1YY tags: - key: owner value: ext-dns - key: parentdomain value: j1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: j2.j1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: f2.f1.ex.com. id: /hostedzone/Z0620221RICGJ8UMXA3J tags: - key: owner value: ext-dns - key: parentdomain value: f1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: f2.f1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: s3.s2.s1.ex.com. id: /hostedzone/Z0633015M1WISEQYPT1O tags: - key: owner value: ext-dns - key: parentdomain value: s2.s1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: s3.s2.s1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: a3.a2.a1.ex.com. id: /hostedzone/Z06329532JLRXZTWQP7A3 tags: - key: owner value: ext-dns - key: parentdomain value: a2.a1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: a3.a2.a1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: m4.m3.m2.m1.ex.com. id: /hostedzone/Z0632386384GR41PHHKYP tags: - key: owner value: ext-dns - key: parentdomain value: m3.m2.m1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: m4.m3.m2.m1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: y4.y3.y2.y1.ex.com. id: /hostedzone/Z06317473A6DQOQB2TRU5 tags: - key: owner value: ext-dns - key: parentdomain value: y3.y2.y1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: y4.y3.y2.y1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: k4.k3.k2.k1.ex.com. id: /hostedzone/Z0631662C8V9KGIE114K tags: - key: owner value: ext-dns - key: parentdomain value: k3.k2.k1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: k4.k3.k2.k1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: l4.l3.l2.l1.ex.com. id: /hostedzone/Z0631619300T0491JETF6 tags: - key: owner value: ext-dns - key: parentdomain value: l3.l2.l1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: l4.l3.l2.l1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: c4.c3.c2.c1.ex.com. id: /hostedzone/Z06312335K3AAQ106WOS tags: - key: owner value: ext-dns - key: parentdomain value: c3.c2.c1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: c4.c3.c2.c1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: d5.d4.d3.d2.d1.ex.com. id: /hostedzone/Z06264302YRR5RW36ZQTN tags: - key: owner value: ext-dns - key: parentdomain value: d4.d3.d2.d1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: d5.d4.d3.d2.d1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: n5.n4.n3.n2.n1.ex.com. id: /hostedzone/Z06255473B4E9CCCYXD0C tags: - key: owner value: ext-dns - key: parentdomain value: n4.n3.n2.n1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: n5.n4.n3.n2.n1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: t6.t5.t4.t3.t2.t1.ex.com. id: /hostedzone/Z06247991Z9UOIVBEHBIZ tags: - key: owner value: ext-dns - key: parentdomain value: t5.t4.t3.t2.t1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: t6.t5.t4.t3.t2.t1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: m6.m5.m4.m3.m2.m1.ex.com. id: /hostedzone/Z06248401WRYVSUJIMXGM tags: - key: owner value: ext-dns - key: parentdomain value: m5.m4.m3.m2.m1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: m6.m5.m4.m3.m2.m1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: u5.u4.u3.u2.u1.ex.com. id: /hostedzone/Z06248072H6SA6EBROQVA tags: - key: owner value: ext-dns - key: parentdomain value: u4.u3.u2.u1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: u5.u4.u3.u2.u1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: t5.t4.t3.t2.t1.ex.com. id: /hostedzone/Z062430113Y30HAIE6V8 tags: - key: owner value: ext-dns - key: parentdomain value: t4.t3.t2.t1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: t5.t4.t3.t2.t1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: n6.n5.n4.n3.n2.n1.ex.com. id: /hostedzone/Z06239002LL6XI9BMP5LP tags: - key: owner value: ext-dns - key: parentdomain value: n5.n4.n3.n2.n1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: n6.n5.n4.n3.n2.n1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: u6.u5.u4.u3.u2.u1.ex.com. id: /hostedzone/Z062358930OBBSG0DNV0Y tags: - key: owner value: ext-dns - key: parentdomain value: u5.u4.u3.u2.u1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: u6.u5.u4.u3.u2.u1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: l5.l4.l3.l2.l1.ex.com. id: /hostedzone/Z06227713K2LX8JW32GM0 tags: - key: owner value: ext-dns - key: parentdomain value: l4.l3.l2.l1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: l5.l4.l3.l2.l1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: w5.w4.w3.w2.w1.ex.com. id: /hostedzone/Z06227293A8RSW44CUSQI tags: - key: owner value: ext-dns - key: parentdomain value: w4.w3.w2.w1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: w5.w4.w3.w2.w1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: e6.e5.e4.e3.e2.e1.ex.com. id: /hostedzone/Z06304583NEE5VFLGKEQU tags: - key: owner value: ext-dns - key: parentdomain value: e5.e4.e3.e2.e1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: e6.e5.e4.e3.e2.e1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: b7.b6.b5.b4.b3.b2.b1.ex.com. id: /hostedzone/Z0942661HXLGP9L2YZH3 tags: - key: owner value: ext-dns - key: parentdomain value: b6.b5.b4.b3.b2.b1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: b7.b6.b5.b4.b3.b2.b1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: t7.t6.t5.t4.t3.t2.t1.ex.com. id: /hostedzone/Z0949431OHPKQPZEXGDX tags: - key: owner value: ext-dns - key: parentdomain value: t6.t5.t4.t3.t2.t1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: t7.t6.t5.t4.t3.t2.t1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: d7.d6.d5.d4.d3.d2.d1.ex.com. id: /hostedzone/Z09491241UC9DWHHT0SEN tags: - key: owner value: ext-dns - key: parentdomain value: d6.d5.d4.d3.d2.d1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: d7.d6.d5.d4.d3.d2.d1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: h7.h6.h5.h4.h3.h2.h1.ex.com. id: /hostedzone/Z09480891AX3V95RKI43I tags: - key: owner value: ext-dns - key: parentdomain value: h6.h5.h4.h3.h2.h1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: h7.h6.h5.h4.h3.h2.h1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: g7.g6.g5.g4.g3.g2.g1.ex.com. id: /hostedzone/Z094802628HB653GKGL2W tags: - key: owner value: ext-dns - key: parentdomain value: g6.g5.g4.g3.g2.g1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: g7.g6.g5.g4.g3.g2.g1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: m7.m6.m5.m4.m3.m2.m1.ex.com. id: /hostedzone/Z09480162FW8C3KROXT7G tags: - key: owner value: ext-dns - key: parentdomain value: m6.m5.m4.m3.m2.m1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: m7.m6.m5.m4.m3.m2.m1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: l7.l6.l5.l4.l3.l2.l1.ex.com. id: /hostedzone/Z09480273OHY83D5NHYVH tags: - key: owner value: ext-dns - key: parentdomain value: l6.l5.l4.l3.l2.l1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: l7.l6.l5.l4.l3.l2.l1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: o7.o6.o5.o4.o3.o2.o1.ex.com. id: /hostedzone/Z09480223A3R9GDRXB62G tags: - key: owner value: ext-dns - key: parentdomain value: o6.o5.o4.o3.o2.o1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: o7.o6.o5.o4.o3.o2.o1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: i7.i6.i5.i4.i3.i2.i1.ex.com. id: /hostedzone/Z09475203CZDWQ5UOPB5H tags: - key: owner value: ext-dns - key: parentdomain value: i6.i5.i4.i3.i2.i1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: i7.i6.i5.i4.i3.i2.i1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: e7.e6.e5.e4.e3.e2.e1.ex.com. id: /hostedzone/Z09465491NJY6YM6BV9CR tags: - key: owner value: ext-dns - key: parentdomain value: e6.e5.e4.e3.e2.e1.ex.com - key: level value: "7" - key: managed value: terraform - key: domain value: e7.e6.e5.e4.e3.e2.e1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: w2.w1.ex.com. id: /hostedzone/Z09418121E8V6WT4FASZE tags: - key: owner value: ext-dns - key: parentdomain value: w1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: w2.w1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: t2.t1.ex.com. id: /hostedzone/Z0941411140EAM8LI6XSX tags: - key: owner value: ext-dns - key: parentdomain value: t1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: t2.t1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: m1.ex.com. id: /hostedzone/Z0941151MH351YJEC250 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: m1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: e2.e1.ex.com. id: /hostedzone/Z09408592RKGIVCOGBFJW tags: - key: owner value: ext-dns - key: parentdomain value: e1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: e2.e1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: n2.n1.ex.com. id: /hostedzone/Z09408791OZQTXJTECCDX tags: - key: owner value: ext-dns - key: parentdomain value: n1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: n2.n1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: i1.ex.com. id: /hostedzone/Z0940748ZXY7SLGNELA0 tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: i1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: e1.ex.com. id: /hostedzone/Z094076011ZHUH8WUULP tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: e1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: b1.ex.com. id: /hostedzone/Z09407613O62TF4KJ7UHH tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: b1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: x1.ex.com. id: /hostedzone/Z09407532B9TMSQHCL5QA tags: - key: owner value: ext-dns - key: parentdomain value: ex.com - key: level value: "1" - key: managed value: terraform - key: domain value: x1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: y2.y1.ex.com. id: /hostedzone/Z0940306YB0C0775W4GP tags: - key: owner value: ext-dns - key: parentdomain value: y1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: y2.y1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: h2.h1.ex.com. id: /hostedzone/Z09405092OGSZIJY9BEYN tags: - key: owner value: ext-dns - key: parentdomain value: h1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: h2.h1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: d2.d1.ex.com. id: /hostedzone/Z09396301VGLGYVE5RGK5 tags: - key: owner value: ext-dns - key: parentdomain value: d1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: d2.d1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: s2.s1.ex.com. id: /hostedzone/Z09394103L1W2N920D71K tags: - key: owner value: ext-dns - key: parentdomain value: s1.ex.com - key: level value: "2" - key: managed value: terraform - key: domain value: s2.s1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: f3.f2.f1.ex.com. id: /hostedzone/Z09514023G8ZGR4DJKR3A tags: - key: owner value: ext-dns - key: parentdomain value: f2.f1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: f3.f2.f1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: b3.b2.b1.ex.com. id: /hostedzone/Z09583652IMG5STP7731F tags: - key: owner value: ext-dns - key: parentdomain value: b2.b1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: b3.b2.b1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: w3.w2.w1.ex.com. id: /hostedzone/Z09580483LFSMB2OBV4FM tags: - key: owner value: ext-dns - key: parentdomain value: w2.w1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: w3.w2.w1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: x4.x3.x2.x1.ex.com. id: /hostedzone/Z095764620VD6TFV6Q8FH tags: - key: owner value: ext-dns - key: parentdomain value: x3.x2.x1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: x4.x3.x2.x1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: f4.f3.f2.f1.ex.com. id: /hostedzone/Z09564251P7OWGV8YAVPD tags: - key: owner value: ext-dns - key: parentdomain value: f3.f2.f1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: f4.f3.f2.f1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: z4.z3.z2.z1.ex.com. id: /hostedzone/Z095609014Z6XE70HI4T4 tags: - key: owner value: ext-dns - key: parentdomain value: z3.z2.z1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: z4.z3.z2.z1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: h3.h2.h1.ex.com. id: /hostedzone/Z09557511F8219IOYA0PO tags: - key: owner value: ext-dns - key: parentdomain value: h2.h1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: h3.h2.h1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: b4.b3.b2.b1.ex.com. id: /hostedzone/Z095570265KY2U6HO9TO tags: - key: owner value: ext-dns - key: parentdomain value: b3.b2.b1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: b4.b3.b2.b1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: s4.s3.s2.s1.ex.com. id: /hostedzone/Z0955182J8RQ0EOVX7X5 tags: - key: owner value: ext-dns - key: parentdomain value: s3.s2.s1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: s4.s3.s2.s1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: j4.j3.j2.j1.ex.com. id: /hostedzone/Z09551293SYT29TZGCK1Q tags: - key: owner value: ext-dns - key: parentdomain value: j3.j2.j1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: j4.j3.j2.j1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: i3.i2.i1.ex.com. id: /hostedzone/Z09549753KGGEAJYSIKQU tags: - key: owner value: ext-dns - key: parentdomain value: i2.i1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: i3.i2.i1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: h4.h3.h2.h1.ex.com. id: /hostedzone/Z095473017P9TIG47W418 tags: - key: owner value: ext-dns - key: parentdomain value: h3.h2.h1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: h4.h3.h2.h1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: c3.c2.c1.ex.com. id: /hostedzone/Z09788602NGY8C9CCTV10 tags: - key: owner value: ext-dns - key: parentdomain value: c2.c1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: c3.c2.c1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: t3.t2.t1.ex.com. id: /hostedzone/Z09788491STHS3LT21IWW tags: - key: owner value: ext-dns - key: parentdomain value: t2.t1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: t3.t2.t1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: e3.e2.e1.ex.com. id: /hostedzone/Z09788593ERXU8DSD4LCO tags: - key: owner value: ext-dns - key: parentdomain value: e2.e1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: e3.e2.e1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: u3.u2.u1.ex.com. id: /hostedzone/Z09783123AIJ322RP9AVS tags: - key: owner value: ext-dns - key: parentdomain value: u2.u1.ex.com - key: level value: "3" - key: managed value: terraform - key: domain value: u3.u2.u1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: n4.n3.n2.n1.ex.com. id: /hostedzone/Z0978397265BSNRBCT08T tags: - key: owner value: ext-dns - key: parentdomain value: n3.n2.n1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: n4.n3.n2.n1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: o4.o3.o2.o1.ex.com. id: /hostedzone/Z09753782VB47JVMP3QQH tags: - key: owner value: ext-dns - key: parentdomain value: o3.o2.o1.ex.com - key: level value: "4" - key: managed value: terraform - key: domain value: o4.o3.o2.o1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: b5.b4.b3.b2.b1.ex.com. id: /hostedzone/Z09808623VONT9B5AGIBQ tags: - key: owner value: ext-dns - key: parentdomain value: b4.b3.b2.b1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: b5.b4.b3.b2.b1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: c6.c5.c4.c3.c2.c1.ex.com. id: /hostedzone/Z09705082YEX3UKM47BT3 tags: - key: owner value: ext-dns - key: parentdomain value: c5.c4.c3.c2.c1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: c6.c5.c4.c3.c2.c1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: p5.p4.p3.p2.p1.ex.com. id: /hostedzone/Z09704821Y3VVAC37HSED tags: - key: owner value: ext-dns - key: parentdomain value: p4.p3.p2.p1.ex.com - key: level value: "5" - key: managed value: terraform - key: domain value: p5.p4.p3.p2.p1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: a6.a5.a4.a3.a2.a1.ex.com. id: /hostedzone/Z0969737LNGOLZ7RPT2E tags: - key: owner value: ext-dns - key: parentdomain value: a5.a4.a3.a2.a1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: a6.a5.a4.a3.a2.a1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com - name: f6.f5.f4.f3.f2.f1.ex.com. id: /hostedzone/Z09695981XV9GI7P7WRMX tags: - key: owner value: ext-dns - key: parentdomain value: f5.f4.f3.f2.f1.ex.com - key: level value: "6" - key: managed value: terraform - key: domain value: f6.f5.f4.f3.f2.f1.ex.com - key: vpcid value: vpc-123456 - key: env value: sandbox - key: rootdomain value: ex.com ================================================ FILE: provider/aws/instrumented_config.go ================================================ /* 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 aws import ( "context" "fmt" "time" "github.com/aws/smithy-go/middleware" smithyhttp "github.com/aws/smithy-go/transport/http" extdnshttp "sigs.k8s.io/external-dns/pkg/http" "sigs.k8s.io/external-dns/pkg/metrics" ) type requestMetrics struct { StartTime time.Time } type requestMetricsKey struct{} func getRequestMetric(ctx context.Context) requestMetrics { requestMetrics, _ := middleware.GetStackValue(ctx, requestMetricsKey{}).(requestMetrics) return requestMetrics } func setRequestMetric(ctx context.Context, requestMetrics requestMetrics) context.Context { return middleware.WithStackValue(ctx, requestMetricsKey{}, requestMetrics) } var initializeTimedOperationMiddleware = middleware.InitializeMiddlewareFunc("timedOperation", func( ctx context.Context, in middleware.InitializeInput, next middleware.InitializeHandler, ) (middleware.InitializeOutput, middleware.Metadata, error) { requestMetrics := requestMetrics{} requestMetrics.StartTime = time.Now() ctx = setRequestMetric(ctx, requestMetrics) return next.HandleInitialize(ctx, in) }) var extractAWSRequestParameters = middleware.DeserializeMiddlewareFunc("extractAWSRequestParameters", func( ctx context.Context, in middleware.DeserializeInput, next middleware.DeserializeHandler, ) (middleware.DeserializeOutput, middleware.Metadata, error) { // Call the next middleware first to get the response out, metadata, err := next.HandleDeserialize(ctx, in) requestMetrics := getRequestMetric(ctx) labels := metrics.Labels{} if req, ok := in.Request.(*smithyhttp.Request); ok && req != nil { labels[metrics.LabelScheme] = req.URL.Scheme labels[metrics.LabelHost] = req.URL.Host labels[metrics.LabelPath] = metrics.PathProcessor(req.URL.Path) labels[metrics.LabelMethod] = req.Method labels[metrics.LabelStatus] = "unknown" } // Try to access HTTP response and status code if resp, ok := out.RawResponse.(*smithyhttp.Response); ok && resp != nil { labels[metrics.LabelStatus] = fmt.Sprintf("%d", resp.StatusCode) } extdnshttp.RequestDurationMetric.SetWithLabels(time.Since(requestMetrics.StartTime).Seconds(), labels) return out, metadata, err }) func GetInstrumentationMiddlewares() []func(*middleware.Stack) error { return []func(s *middleware.Stack) error{ func(s *middleware.Stack) error { if err := s.Initialize.Add(initializeTimedOperationMiddleware, middleware.Before); err != nil { return fmt.Errorf("error adding timedOperationMiddleware: %w", err) } if err := s.Deserialize.Add(extractAWSRequestParameters, middleware.After); err != nil { return fmt.Errorf("error adding extractAWSRequestParameters: %w", err) } return nil }, } } ================================================ FILE: provider/aws/instrumented_config_test.go ================================================ /* 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 aws import ( "context" "net/http" "net/url" "testing" "time" "github.com/aws/smithy-go/middleware" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" smithyhttp "github.com/aws/smithy-go/transport/http" ) func Test_GetInstrumentationMiddlewares(t *testing.T) { t.Run("adds expected middlewares", func(t *testing.T) { stack := middleware.NewStack("test-stack", nil) for _, mw := range GetInstrumentationMiddlewares() { err := mw(stack) require.NoError(t, err) } // Check Initialize stage timedOperationMiddleware, found := stack.Initialize.Get("timedOperation") assert.True(t, found, "timedOperation middleware should be present in Initialize stage") assert.NotNil(t, timedOperationMiddleware) // Check Deserialize stage extractAWSRequestParametersMiddleware, found := stack.Deserialize.Get("extractAWSRequestParameters") assert.True(t, found, "extractAWSRequestParameters middleware should be present in Deserialize stage") assert.NotNil(t, extractAWSRequestParametersMiddleware) }) } type MockInitializeHandler struct { CapturedContext context.Context } func (mock *MockInitializeHandler) HandleInitialize(ctx context.Context, _ middleware.InitializeInput) (middleware.InitializeOutput, middleware.Metadata, error) { mock.CapturedContext = ctx return middleware.InitializeOutput{}, middleware.Metadata{}, nil } func Test_InitializedTimedOperationMiddleware(t *testing.T) { testContext := t.Context() mockInitializeHandler := &MockInitializeHandler{} _, _, err := initializeTimedOperationMiddleware.HandleInitialize(testContext, middleware.InitializeInput{}, mockInitializeHandler) require.NoError(t, err) requestMetrics := middleware.GetStackValue(mockInitializeHandler.CapturedContext, requestMetricsKey{}).(requestMetrics) assert.NotNil(t, requestMetrics.StartTime) } type MockDeserializeHandler struct { } func (mock *MockDeserializeHandler) HandleDeserialize(_ context.Context, _ middleware.DeserializeInput) (middleware.DeserializeOutput, middleware.Metadata, error) { return middleware.DeserializeOutput{}, middleware.Metadata{}, nil } func Test_ExtractAWSRequestParameters(t *testing.T) { testContext := t.Context() middleware.WithStackValue(testContext, requestMetricsKey{}, requestMetrics{StartTime: time.Now()}) mockDeserializeHandler := &MockDeserializeHandler{} deserializeInput := middleware.DeserializeInput{ Request: &smithyhttp.Request{ Request: &http.Request{ Method: http.MethodGet, URL: &url.URL{ Host: "example.com", Scheme: "HTTPS", Path: "/testPath", }, }, }, } _, _, err := extractAWSRequestParameters.HandleDeserialize(testContext, deserializeInput, mockDeserializeHandler) require.NoError(t, err) } ================================================ FILE: provider/awssd/aws_sd.go ================================================ /* Copyright 2018 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 awssd import ( "context" "crypto/sha256" "encoding/hex" "fmt" "regexp" "strings" "github.com/aws/aws-sdk-go-v2/aws" sd "github.com/aws/aws-sdk-go-v2/service/servicediscovery" sdtypes "github.com/aws/aws-sdk-go-v2/service/servicediscovery/types" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" extdnsaws "sigs.k8s.io/external-dns/provider/aws" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( defaultTTL = 300 // https://github.com/aws/aws-sdk-go-v2/blob/cf8509382340d6afdc93612550d56d685181bbb3/service/servicediscovery/api_op_ListServices.go#L42 maxResults = 100 sdNamespaceTypePublic = "public" sdNamespaceTypePrivate = "private" sdInstanceAttrIPV4 = "AWS_INSTANCE_IPV4" sdInstanceAttrIPV6 = "AWS_INSTANCE_IPV6" sdInstanceAttrCname = "AWS_INSTANCE_CNAME" sdInstanceAttrAlias = "AWS_ALIAS_DNS_NAME" ) var ( // matches ELB with hostname format load-balancer.us-east-1.elb.amazonaws.com sdElbHostnameRegex = regexp.MustCompile(`.+\.[^.]+\.elb\.amazonaws\.com$`) // matches NLB with hostname format load-balancer.elb.us-east-1.amazonaws.com sdNlbHostnameRegex = regexp.MustCompile(`.+\.elb\.[^.]+\.amazonaws\.com$`) ) // AWSSDClient is the subset of the AWS Cloud Map API that we actually use. Add methods as required. // Signatures must match exactly. Taken from https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/servicediscovery type AWSSDClient interface { CreateService(ctx context.Context, params *sd.CreateServiceInput, optFns ...func(*sd.Options)) (*sd.CreateServiceOutput, error) DeregisterInstance(ctx context.Context, params *sd.DeregisterInstanceInput, optFns ...func(*sd.Options)) (*sd.DeregisterInstanceOutput, error) DiscoverInstances(ctx context.Context, params *sd.DiscoverInstancesInput, optFns ...func(*sd.Options)) (*sd.DiscoverInstancesOutput, error) ListNamespaces(ctx context.Context, params *sd.ListNamespacesInput, optFns ...func(*sd.Options)) (*sd.ListNamespacesOutput, error) ListServices(ctx context.Context, params *sd.ListServicesInput, optFns ...func(*sd.Options)) (*sd.ListServicesOutput, error) RegisterInstance(ctx context.Context, params *sd.RegisterInstanceInput, optFns ...func(*sd.Options)) (*sd.RegisterInstanceOutput, error) UpdateService(ctx context.Context, params *sd.UpdateServiceInput, optFns ...func(*sd.Options)) (*sd.UpdateServiceOutput, error) DeleteService(ctx context.Context, params *sd.DeleteServiceInput, optFns ...func(*sd.Options)) (*sd.DeleteServiceOutput, error) } // AWSSDProvider is an implementation of Provider for AWS Cloud Map. type AWSSDProvider struct { provider.BaseProvider client AWSSDClient dryRun bool // only consider namespaces ending in this suffix namespaceFilter *endpoint.DomainFilter // filter namespace by type (private or public) namespaceTypeFilter []sdtypes.NamespaceFilter // enables service without instances cleanup cleanEmptyService bool // filter services for removal ownerID string // tags to be added to the service tags []sdtypes.Tag } // New creates an AWS Service Discovery provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { // Check that only compatible Registry is used with AWS-SD if cfg.Registry != "noop" && cfg.Registry != "aws-sd" { log.Infof("Registry \"%s\" cannot be used with AWS Cloud Map. Switching to \"aws-sd\".", cfg.Registry) cfg.Registry = "aws-sd" } return newProvider(domainFilter, cfg.AWSZoneType, cfg.DryRun, cfg.AWSSDServiceCleanup, cfg.TXTOwnerID, cfg.AWSSDCreateTag, sd.NewFromConfig(extdnsaws.CreateDefaultV2Config(cfg))), nil } // newProvider initializes a new AWS Cloud Map based Provider. func newProvider(domainFilter *endpoint.DomainFilter, namespaceType string, dryRun, cleanEmptyService bool, ownerID string, tags map[string]string, client AWSSDClient) *AWSSDProvider { p := &AWSSDProvider{ client: client, dryRun: dryRun, namespaceFilter: domainFilter, namespaceTypeFilter: newSdNamespaceFilter(namespaceType), cleanEmptyService: cleanEmptyService, ownerID: ownerID, tags: awsTags(tags), } return p } // newSdNamespaceFilter returns NamespaceFilter based on the given namespace type configuration. // If the config is "public", it filters for public namespaces; if "private", for private namespaces. // For any other value (including empty), it returns filters for both public and private namespaces. // ref: https://docs.aws.amazon.com/cloud-map/latest/api/API_ListNamespaces.html func newSdNamespaceFilter(namespaceTypeConfig string) []sdtypes.NamespaceFilter { switch namespaceTypeConfig { case sdNamespaceTypePublic: return []sdtypes.NamespaceFilter{ { Name: sdtypes.NamespaceFilterNameType, Values: []string{string(sdtypes.NamespaceTypeDnsPublic)}, }, } case sdNamespaceTypePrivate: return []sdtypes.NamespaceFilter{ { Name: sdtypes.NamespaceFilterNameType, Values: []string{string(sdtypes.NamespaceTypeDnsPrivate)}, }, } default: return []sdtypes.NamespaceFilter{} } } // awsTags converts user-supplied tags to AWS format func awsTags(tags map[string]string) []sdtypes.Tag { awsTags := make([]sdtypes.Tag, 0, len(tags)) for k, v := range tags { awsTags = append(awsTags, sdtypes.Tag{Key: aws.String(k), Value: aws.String(v)}) } return awsTags } // Records returns list of all endpoints. func (p *AWSSDProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { namespaces, err := p.ListNamespaces(ctx) if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, 0) for _, ns := range namespaces { services, err := p.ListServicesByNamespaceID(ctx, ns.Id) if err != nil { return nil, err } for _, srv := range services { resp, err := p.client.DiscoverInstances(ctx, &sd.DiscoverInstancesInput{ NamespaceName: ns.Name, ServiceName: srv.Name, }) if err != nil { return nil, err } if len(resp.Instances) == 0 { if err := p.DeleteService(ctx, srv); err != nil { log.Errorf("Failed to delete service %q, error: %s", *srv.Name, err) } continue } if srv.Description == nil { log.Warnf("Skipping service %q as owner id not configured", *srv.Name) continue } endpoints = append(endpoints, p.instancesToEndpoint(ns, srv, resp.Instances)) } } return endpoints, nil } func (p *AWSSDProvider) instancesToEndpoint(ns *sdtypes.NamespaceSummary, srv *sdtypes.Service, instances []sdtypes.HttpInstanceSummary) *endpoint.Endpoint { // DNS name of the record is a concatenation of service and namespace recordName := *srv.Name + "." + *ns.Name labels := endpoint.NewLabels() labels[endpoint.AWSSDDescriptionLabel] = *srv.Description newEndpoint := &endpoint.Endpoint{ DNSName: recordName, RecordTTL: endpoint.TTL(*srv.DnsConfig.DnsRecords[0].TTL), Targets: make(endpoint.Targets, 0, len(instances)), Labels: labels, } for _, inst := range instances { switch { // CNAME case inst.Attributes[sdInstanceAttrCname] != "" && srv.DnsConfig.DnsRecords[0].Type == sdtypes.RecordTypeCname: newEndpoint.RecordType = endpoint.RecordTypeCNAME newEndpoint.Targets = append(newEndpoint.Targets, inst.Attributes[sdInstanceAttrCname]) // ALIAS case inst.Attributes[sdInstanceAttrAlias] != "": newEndpoint.RecordType = endpoint.RecordTypeCNAME newEndpoint.Targets = append(newEndpoint.Targets, inst.Attributes[sdInstanceAttrAlias]) // IPv4-based target case inst.Attributes[sdInstanceAttrIPV4] != "": newEndpoint.RecordType = endpoint.RecordTypeA newEndpoint.Targets = append(newEndpoint.Targets, inst.Attributes[sdInstanceAttrIPV4]) // IPv6-based target case inst.Attributes[sdInstanceAttrIPV6] != "": newEndpoint.RecordType = endpoint.RecordTypeAAAA newEndpoint.Targets = append(newEndpoint.Targets, inst.Attributes[sdInstanceAttrIPV6]) default: log.Warnf("Invalid instance \"%v\" found in service \"%v\"", inst, srv.Name) } } return newEndpoint } // ApplyChanges applies Kubernetes changes in endpoints to AWS API func (p *AWSSDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { // return early if there is nothing to change if len(changes.Create) == 0 && len(changes.Delete) == 0 && len(changes.UpdateNew) == 0 { log.Info("All records are already up to date") return nil } // convert updates to delete and create operation if applicable (updates not supported) creates, deletes := p.updatesToCreates(changes) changes.Delete = append(changes.Delete, deletes...) changes.Create = append(changes.Create, creates...) namespaces, err := p.ListNamespaces(ctx) if err != nil { return err } err = p.submitDeletes(ctx, namespaces, changes.Delete) if err != nil { return err } err = p.submitCreates(ctx, namespaces, changes.Create) if err != nil { return err } return nil } func (p *AWSSDProvider) updatesToCreates(changes *plan.Changes) ([]*endpoint.Endpoint, []*endpoint.Endpoint) { updateNewMap := map[string]*endpoint.Endpoint{} for _, e := range changes.UpdateNew { updateNewMap[e.DNSName] = e } var creates, deletes []*endpoint.Endpoint for _, old := range changes.UpdateOld { current := updateNewMap[old.DNSName] if !old.Targets.Same(current.Targets) { currentTargetsMap := make(map[string]struct{}, len(current.Targets)) for _, newTarget := range current.Targets { currentTargetsMap[newTarget] = struct{}{} } // If targets changed, only deregister removed targets (i.e. in `UpdateOld` but not in `UpdateNew`) targetsToRemove := make(endpoint.Targets, 0) for _, oldTarget := range old.Targets { if _, found := currentTargetsMap[oldTarget]; !found { targetsToRemove = append(targetsToRemove, oldTarget) } } old.Targets = targetsToRemove deletes = append(deletes, old) } // always register (or re-register) instance with the current data creates = append(creates, current) } return creates, deletes } func (p *AWSSDProvider) submitCreates(ctx context.Context, namespaces []*sdtypes.NamespaceSummary, changes []*endpoint.Endpoint) error { changesByNamespaceID := p.changesByNamespaceID(namespaces, changes) for nsID, changeList := range changesByNamespaceID { services, err := p.ListServicesByNamespaceID(ctx, aws.String(nsID)) if err != nil { return err } for _, ch := range changeList { _, srvName := p.parseHostname(ch.DNSName) srv := services[srvName] if srv == nil { // when service is missing create a new one srv, err = p.CreateService(ctx, &nsID, &srvName, ch) if err != nil { return err } // update a local list of services services[*srv.Name] = srv } else if ch.RecordTTL.IsConfigured() && *srv.DnsConfig.DnsRecords[0].TTL != int64(ch.RecordTTL) { // update service when TTL differ err = p.UpdateService(ctx, srv, ch) if err != nil { return err } } err = p.RegisterInstance(ctx, srv, ch) if err != nil { return err } } } return nil } func (p *AWSSDProvider) submitDeletes(ctx context.Context, namespaces []*sdtypes.NamespaceSummary, changes []*endpoint.Endpoint) error { changesByNamespaceID := p.changesByNamespaceID(namespaces, changes) for nsID, changeList := range changesByNamespaceID { services, err := p.ListServicesByNamespaceID(ctx, aws.String(nsID)) if err != nil { return err } for _, ch := range changeList { hostname := ch.DNSName _, srvName := p.parseHostname(hostname) srv := services[srvName] if srv == nil { return fmt.Errorf("service \"%s\" is missing when trying to delete \"%v\"", srvName, hostname) } err := p.DeregisterInstance(ctx, srv, ch) if err != nil { return err } } } return nil } // ListNamespaces returns all namespaces matching defined namespace filter func (p *AWSSDProvider) ListNamespaces(ctx context.Context) ([]*sdtypes.NamespaceSummary, error) { namespaces := make([]*sdtypes.NamespaceSummary, 0) paginator := sd.NewListNamespacesPaginator(p.client, &sd.ListNamespacesInput{ Filters: p.namespaceTypeFilter, }) for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) if err != nil { return nil, err } for _, ns := range resp.Namespaces { if !p.namespaceFilter.Match(*ns.Name) { continue } namespaces = append(namespaces, &ns) } } return namespaces, nil } // ListServicesByNamespaceID returns a list of services in a given namespace. func (p *AWSSDProvider) ListServicesByNamespaceID(ctx context.Context, namespaceID *string) (map[string]*sdtypes.Service, error) { services := make([]sdtypes.ServiceSummary, 0) paginator := sd.NewListServicesPaginator(p.client, &sd.ListServicesInput{ Filters: []sdtypes.ServiceFilter{{ Name: sdtypes.ServiceFilterNameNamespaceId, Values: []string{*namespaceID}, }}, MaxResults: aws.Int32(maxResults), }) for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) if err != nil { return nil, err } services = append(services, resp.Services...) } servicesMap := make(map[string]*sdtypes.Service) for _, serviceSummary := range services { service := &sdtypes.Service{ Arn: serviceSummary.Arn, CreateDate: serviceSummary.CreateDate, Description: serviceSummary.Description, DnsConfig: serviceSummary.DnsConfig, HealthCheckConfig: serviceSummary.HealthCheckConfig, HealthCheckCustomConfig: serviceSummary.HealthCheckCustomConfig, Id: serviceSummary.Id, InstanceCount: serviceSummary.InstanceCount, Name: serviceSummary.Name, NamespaceId: namespaceID, Type: serviceSummary.Type, } servicesMap[*service.Name] = service } return servicesMap, nil } // CreateService creates a new service in AWS API. Returns the created service. func (p *AWSSDProvider) CreateService(ctx context.Context, namespaceID *string, srvName *string, ep *endpoint.Endpoint) (*sdtypes.Service, error) { log.Infof("Creating a new service \"%s\" in \"%s\" namespace", *srvName, *namespaceID) srvType := p.serviceTypeFromEndpoint(ep) routingPolicy := p.routingPolicyFromEndpoint(ep) ttl := int64(defaultTTL) if ep.RecordTTL.IsConfigured() { ttl = int64(ep.RecordTTL) } if p.dryRun { // return a mock service summary in case of a dry run return &sdtypes.Service{Id: aws.String("dry-run-service"), Name: aws.String("dry-run-service")}, nil } out, err := p.client.CreateService(ctx, &sd.CreateServiceInput{ Name: srvName, Description: aws.String(ep.Labels[endpoint.AWSSDDescriptionLabel]), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: routingPolicy, DnsRecords: []sdtypes.DnsRecord{{ Type: srvType, TTL: aws.Int64(ttl), }}, }, NamespaceId: namespaceID, Tags: p.tags, }) if err != nil { return nil, err } return out.Service, nil } // UpdateService updates the specified service with information from the provided endpoint. func (p *AWSSDProvider) UpdateService(ctx context.Context, service *sdtypes.Service, ep *endpoint.Endpoint) error { log.Infof("Updating service \"%s\"", *service.Name) srvType := p.serviceTypeFromEndpoint(ep) ttl := int64(defaultTTL) if ep.RecordTTL.IsConfigured() { ttl = int64(ep.RecordTTL) } if p.dryRun { return nil } _, err := p.client.UpdateService(ctx, &sd.UpdateServiceInput{ Id: service.Id, Service: &sdtypes.ServiceChange{ Description: aws.String(ep.Labels[endpoint.AWSSDDescriptionLabel]), DnsConfig: &sdtypes.DnsConfigChange{ DnsRecords: []sdtypes.DnsRecord{{ Type: srvType, TTL: aws.Int64(ttl), }}, }, }, }) return err } // DeleteService deletes empty Service from AWS API if its owner id match func (p *AWSSDProvider) DeleteService(ctx context.Context, service *sdtypes.Service) error { log.Debugf("Check if service \"%s\" owner id match and it can be deleted", *service.Name) if p.dryRun || !p.cleanEmptyService { return nil } // convert ownerID string to the service description format label := endpoint.NewLabels() label[endpoint.OwnerLabelKey] = p.ownerID label[endpoint.AWSSDDescriptionLabel] = label.SerializePlain(false) if service.Description == nil { log.Debugf("Skipping service removal %q because owner id (service.Description) not set, when should be %q", *service.Name, label[endpoint.AWSSDDescriptionLabel]) return nil } if strings.HasPrefix(*service.Description, label[endpoint.AWSSDDescriptionLabel]) { log.Infof("Deleting service \"%s\"", *service.Name) _, err := p.client.DeleteService(ctx, &sd.DeleteServiceInput{ Id: aws.String(*service.Id), }) return err } log.Debugf("Skipping service removal %q because owner id does not match, found: %q, required: %q", *service.Name, *service.Description, label[endpoint.AWSSDDescriptionLabel]) return nil } // RegisterInstance creates a new instance in given service. func (p *AWSSDProvider) RegisterInstance(ctx context.Context, service *sdtypes.Service, ep *endpoint.Endpoint) error { for _, target := range ep.Targets { log.Infof("Registering a new instance \"%s\" for service \"%s\" (%s)", target, *service.Name, *service.Id) attr := make(map[string]string) switch ep.RecordType { case endpoint.RecordTypeCNAME: if p.isAWSLoadBalancer(target) { attr[sdInstanceAttrAlias] = target } else { attr[sdInstanceAttrCname] = target } case endpoint.RecordTypeA: attr[sdInstanceAttrIPV4] = target case endpoint.RecordTypeAAAA: attr[sdInstanceAttrIPV6] = target default: return fmt.Errorf("invalid endpoint type (%v)", ep) } if !p.dryRun { _, err := p.client.RegisterInstance(ctx, &sd.RegisterInstanceInput{ ServiceId: service.Id, Attributes: attr, InstanceId: aws.String(p.targetToInstanceID(target)), }) if err != nil { return err } } } return nil } // DeregisterInstance removes an instance from given service. func (p *AWSSDProvider) DeregisterInstance(ctx context.Context, service *sdtypes.Service, ep *endpoint.Endpoint) error { for _, target := range ep.Targets { log.Infof("De-registering an instance \"%s\" for service \"%s\" (%s)", target, *service.Name, *service.Id) if !p.dryRun { _, err := p.client.DeregisterInstance(ctx, &sd.DeregisterInstanceInput{ InstanceId: aws.String(p.targetToInstanceID(target)), ServiceId: service.Id, }) if err != nil { return err } } } return nil } // Instance ID length is limited by AWS API to 64 characters. For longer strings SHA-256 hash will be used instead of // the verbatim target to limit the length. func (p *AWSSDProvider) targetToInstanceID(target string) string { if len(target) > 64 { hash := sha256.Sum256([]byte(strings.ToLower(target))) return hex.EncodeToString(hash[:]) } return strings.ToLower(target) } func (p *AWSSDProvider) changesByNamespaceID(namespaces []*sdtypes.NamespaceSummary, changes []*endpoint.Endpoint) map[string][]*endpoint.Endpoint { changesByNsID := make(map[string][]*endpoint.Endpoint) for _, ns := range namespaces { changesByNsID[*ns.Id] = []*endpoint.Endpoint{} } for _, c := range changes { // trim the trailing dot from hostname if any hostname := strings.TrimSuffix(c.DNSName, ".") nsName, _ := p.parseHostname(hostname) matchingNamespaces := matchingNamespaces(nsName, namespaces) if len(matchingNamespaces) == 0 { log.Warnf("Skipping record %s because no namespace matching record DNS Name was detected ", c.String()) continue } for _, ns := range matchingNamespaces { changesByNsID[*ns.Id] = append(changesByNsID[*ns.Id], c) } } // separating a change could lead to empty sub changes, remove them here. for zone, change := range changesByNsID { if len(change) == 0 { delete(changesByNsID, zone) } } return changesByNsID } // returns list of all namespaces matching given hostname func matchingNamespaces(hostname string, namespaces []*sdtypes.NamespaceSummary) []*sdtypes.NamespaceSummary { matchingNamespaces := make([]*sdtypes.NamespaceSummary, 0) for _, ns := range namespaces { if *ns.Name == hostname { matchingNamespaces = append(matchingNamespaces, ns) } } return matchingNamespaces } // parseHostname parse hostname to namespace (domain) and service func (p *AWSSDProvider) parseHostname(hostname string) (string, string) { parts := strings.Split(hostname, ".") return strings.Join(parts[1:], "."), parts[0] } // determine service routing policy based on endpoint type func (p *AWSSDProvider) routingPolicyFromEndpoint(ep *endpoint.Endpoint) sdtypes.RoutingPolicy { if ep.RecordType == endpoint.RecordTypeA || ep.RecordType == endpoint.RecordTypeAAAA { return sdtypes.RoutingPolicyMultivalue } return sdtypes.RoutingPolicyWeighted } // determine the service type (A, AAAA, CNAME) from a given endpoint func (p *AWSSDProvider) serviceTypeFromEndpoint(ep *endpoint.Endpoint) sdtypes.RecordType { switch ep.RecordType { case endpoint.RecordTypeCNAME: // FIXME service type is derived from the first target only. Theoretically this may be problem. // But I don't see a scenario where one endpoint contains targets of different types. if p.isAWSLoadBalancer(ep.Targets[0]) { // ALIAS target uses DNS record of type A return sdtypes.RecordTypeA } return sdtypes.RecordTypeCname case endpoint.RecordTypeAAAA: return sdtypes.RecordTypeAaaa default: return sdtypes.RecordTypeA } } // determine if a given hostname belongs to an AWS load balancer func (p *AWSSDProvider) isAWSLoadBalancer(hostname string) bool { matchElb := sdElbHostnameRegex.MatchString(hostname) matchNlb := sdNlbHostnameRegex.MatchString(hostname) return matchElb || matchNlb } ================================================ FILE: provider/awssd/aws_sd_test.go ================================================ /* Copyright 2018 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 awssd import ( "reflect" "testing" "github.com/aws/aws-sdk-go-v2/aws" sdtypes "github.com/aws/aws-sdk-go-v2/service/servicediscovery/types" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/plan" ) func TestAWSSDProvider_Records(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "a-srv": { Id: aws.String("a-srv"), Name: aws.String("service1"), NamespaceId: aws.String("private"), Description: aws.String("owner-id"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeA, TTL: aws.Int64(100), }}, }, }, "alias-srv": { Id: aws.String("alias-srv"), Name: aws.String("service2"), NamespaceId: aws.String("private"), Description: aws.String("owner-id"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeA, TTL: aws.Int64(100), }}, }, }, "cname-srv": { Id: aws.String("cname-srv"), Name: aws.String("service3"), NamespaceId: aws.String("private"), Description: aws.String("owner-id"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeCname, TTL: aws.Int64(80), }}, }, }, "aaaa-srv": { Id: aws.String("aaaa-srv"), Name: aws.String("service4"), Description: aws.String("owner-id"), DnsConfig: &sdtypes.DnsConfig{ NamespaceId: aws.String("private"), RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeAaaa, TTL: aws.Int64(100), }}, }, }, "aaaa-srv-not-managed-without-owner-id": { Id: aws.String("aaaa-srv"), Name: aws.String("service5"), Description: nil, DnsConfig: &sdtypes.DnsConfig{ NamespaceId: aws.String("private"), RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeAaaa, TTL: aws.Int64(100), }}, }, }, }, } instances := map[string]map[string]*sdtypes.Instance{ "a-srv": { "1.2.3.4": { Id: aws.String("1.2.3.4"), Attributes: map[string]string{ sdInstanceAttrIPV4: "1.2.3.4", }, }, "1.2.3.5": { Id: aws.String("1.2.3.5"), Attributes: map[string]string{ sdInstanceAttrIPV4: "1.2.3.5", }, }, }, "alias-srv": { "load-balancer.us-east-1.elb.amazonaws.com": { Id: aws.String("load-balancer.us-east-1.elb.amazonaws.com"), Attributes: map[string]string{ sdInstanceAttrAlias: "load-balancer.us-east-1.elb.amazonaws.com", }, }, }, "cname-srv": { "cname.target.com": { Id: aws.String("cname.target.com"), Attributes: map[string]string{ sdInstanceAttrCname: "cname.target.com", }, }, }, "aaaa-srv": { "0000:0000:0000:0000:abcd:abcd:abcd:abcd": { Id: aws.String("0000:0000:0000:0000:abcd:abcd:abcd:abcd"), Attributes: map[string]string{ sdInstanceAttrIPV6: "0000:0000:0000:0000:abcd:abcd:abcd:abcd", }, }, }, } expectedEndpoints := []*endpoint.Endpoint{ {DNSName: "service1.private.com", Targets: endpoint.Targets{"1.2.3.4", "1.2.3.5"}, RecordType: endpoint.RecordTypeA, RecordTTL: 100, Labels: map[string]string{endpoint.AWSSDDescriptionLabel: "owner-id"}}, {DNSName: "service2.private.com", Targets: endpoint.Targets{"load-balancer.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 100, Labels: map[string]string{endpoint.AWSSDDescriptionLabel: "owner-id"}}, {DNSName: "service3.private.com", Targets: endpoint.Targets{"cname.target.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 80, Labels: map[string]string{endpoint.AWSSDDescriptionLabel: "owner-id"}}, {DNSName: "service4.private.com", Targets: endpoint.Targets{"0000:0000:0000:0000:abcd:abcd:abcd:abcd"}, RecordType: endpoint.RecordTypeAAAA, RecordTTL: 100, Labels: map[string]string{endpoint.AWSSDDescriptionLabel: "owner-id"}}, } api := &AWSSDClientStub{ namespaces: namespaces, services: services, instances: instances, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") endpoints, _ := provider.Records(t.Context()) assert.True(t, testutils.SameEndpoints(expectedEndpoints, endpoints), "expected and actual endpoints don't match, expected=%v, actual=%v", expectedEndpoints, endpoints) } func TestAWSSDProvider_ApplyChanges(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: make(map[string]map[string]*sdtypes.Service), instances: make(map[string]map[string]*sdtypes.Instance), } expectedEndpoints := []*endpoint.Endpoint{ {DNSName: "service1.private.com", Targets: endpoint.Targets{"1.2.3.4", "1.2.3.5"}, RecordType: endpoint.RecordTypeA, RecordTTL: 60}, {DNSName: "service2.private.com", Targets: endpoint.Targets{"load-balancer.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 80}, {DNSName: "service3.private.com", Targets: endpoint.Targets{"cname.target.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 100}, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") ctx := t.Context() // apply creates err := provider.ApplyChanges(ctx, &plan.Changes{ Create: expectedEndpoints, }) assert.NoError(t, err) // make sure services were created assert.Len(t, api.services["private"], 3) existingServices, _ := provider.ListServicesByNamespaceID(t.Context(), namespaces["private"].Id) assert.NotNil(t, existingServices["service1"]) assert.NotNil(t, existingServices["service2"]) assert.NotNil(t, existingServices["service3"]) // make sure instances were registered endpoints, _ := provider.Records(ctx) assert.True(t, testutils.SameEndpoints(expectedEndpoints, endpoints), "expected and actual endpoints don't match, expected=%v, actual=%v", expectedEndpoints, endpoints) ctx = t.Context() // apply deletes err = provider.ApplyChanges(ctx, &plan.Changes{ Delete: expectedEndpoints, }) assert.NoError(t, err) // make sure all instances are gone endpoints, _ = provider.Records(ctx) assert.Empty(t, endpoints) } func TestAWSSDProvider_ApplyChanges_Update(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: make(map[string]map[string]*sdtypes.Service), instances: make(map[string]map[string]*sdtypes.Instance), } oldEndpoints := []*endpoint.Endpoint{ {DNSName: "service1.private.com", Targets: endpoint.Targets{"1.2.3.4", "1.2.3.5"}, RecordType: endpoint.RecordTypeA, RecordTTL: 60}, } newEndpoints := []*endpoint.Endpoint{ {DNSName: "service1.private.com", Targets: endpoint.Targets{"1.2.3.4", "1.2.3.6"}, RecordType: endpoint.RecordTypeA, RecordTTL: 60}, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") ctx := t.Context() // apply creates _ = provider.ApplyChanges(ctx, &plan.Changes{ Create: oldEndpoints, }) ctx = t.Context() // apply update _ = provider.ApplyChanges(ctx, &plan.Changes{ UpdateOld: oldEndpoints, UpdateNew: newEndpoints, }) // make sure services were created assert.Len(t, api.services["private"], 1) existingServices, _ := provider.ListServicesByNamespaceID(ctx, namespaces["private"].Id) assert.NotNil(t, existingServices["service1"]) // make sure instances were registered endpoints, _ := provider.Records(ctx) assert.True(t, testutils.SameEndpoints(newEndpoints, endpoints), "expected and actual endpoints don't match, expected=%v, actual=%v", newEndpoints, endpoints) // make sure only one instance is de-registered assert.Len(t, api.deregistered, 1) assert.Equal(t, "1.2.3.5", api.deregistered[0], "wrong target de-registered") } func TestAWSSDProvider_ListNamespaces(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, "public": { Id: aws.String("public"), Name: aws.String("public.com"), Type: sdtypes.NamespaceTypeDnsPublic, }, } api := &AWSSDClientStub{ namespaces: namespaces, } for _, tc := range []struct { msg string domainFilter *endpoint.DomainFilter namespaceTypeFilter string expectedNamespaces []*sdtypes.NamespaceSummary }{ {"public filter", endpoint.NewDomainFilter([]string{}), "public", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces["public"])}}, {"private filter", endpoint.NewDomainFilter([]string{}), "private", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces["private"])}}, {"optional filter", endpoint.NewDomainFilter([]string{}), "", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces["public"]), namespaceToNamespaceSummary(namespaces["private"])}}, {"domain filter", endpoint.NewDomainFilter([]string{"public.com"}), "", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces["public"])}}, {"non-existing domain", endpoint.NewDomainFilter([]string{"xxx.com"}), "", []*sdtypes.NamespaceSummary{}}, } { provider := newTestAWSSDProvider(api, tc.domainFilter, tc.namespaceTypeFilter, "") result, err := provider.ListNamespaces(t.Context()) require.NoError(t, err) expectedMap := make(map[string]*sdtypes.NamespaceSummary) resultMap := make(map[string]*sdtypes.NamespaceSummary) for _, ns := range tc.expectedNamespaces { expectedMap[*ns.Id] = ns } for _, ns := range result { resultMap[*ns.Id] = ns } if !reflect.DeepEqual(resultMap, expectedMap) { t.Errorf("AWSSDProvider.ListNamespaces() error = %v, wantErr %v", result, tc.expectedNamespaces) } } } func TestAWSSDProvider_ListServicesByNamespace(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, "public": { Id: aws.String("public"), Name: aws.String("public.com"), Type: sdtypes.NamespaceTypeDnsPublic, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "srv1": { Id: aws.String("srv1"), Name: aws.String("service1"), NamespaceId: aws.String("private"), }, "srv2": { Id: aws.String("srv2"), Name: aws.String("service2"), NamespaceId: aws.String("private"), }, }, "public": { "srv3": { Id: aws.String("srv3"), Name: aws.String("service3"), NamespaceId: aws.String("public"), }, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: services, } for _, tc := range []struct { expectedServices map[string]*sdtypes.Service }{ {map[string]*sdtypes.Service{"service1": services["private"]["srv1"], "service2": services["private"]["srv2"]}}, } { provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") result, err := provider.ListServicesByNamespaceID(t.Context(), namespaces["private"].Id) require.NoError(t, err) assert.Equal(t, tc.expectedServices, result) } } func TestAWSSDProvider_CreateService(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: make(map[string]map[string]*sdtypes.Service), } expectedServices := make(map[string]*sdtypes.Service) provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") // A type _, err := provider.CreateService(t.Context(), aws.String("private"), aws.String("A-srv"), &endpoint.Endpoint{ Labels: map[string]string{ endpoint.AWSSDDescriptionLabel: "A-srv", }, RecordType: endpoint.RecordTypeA, RecordTTL: 60, Targets: endpoint.Targets{"1.2.3.4"}, }) assert.NoError(t, err) expectedServices["A-srv"] = &sdtypes.Service{ Name: aws.String("A-srv"), Description: aws.String("A-srv"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyMultivalue, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeA, TTL: aws.Int64(60), }}, }, NamespaceId: aws.String("private"), } // AAAA type _, err = provider.CreateService(t.Context(), aws.String("private"), aws.String("AAAA-srv"), &endpoint.Endpoint{ Labels: map[string]string{ endpoint.AWSSDDescriptionLabel: "AAAA-srv", }, RecordType: endpoint.RecordTypeAAAA, RecordTTL: 60, Targets: endpoint.Targets{"::1234:5678:"}, }) assert.NoError(t, err) expectedServices["AAAA-srv"] = &sdtypes.Service{ Name: aws.String("AAAA-srv"), Description: aws.String("AAAA-srv"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyMultivalue, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeAaaa, TTL: aws.Int64(60), }}, }, NamespaceId: aws.String("private"), } // CNAME type _, err = provider.CreateService(t.Context(), aws.String("private"), aws.String("CNAME-srv"), &endpoint.Endpoint{ Labels: map[string]string{ endpoint.AWSSDDescriptionLabel: "CNAME-srv", }, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 80, Targets: endpoint.Targets{"cname.target.com"}, }) assert.NoError(t, err) expectedServices["CNAME-srv"] = &sdtypes.Service{ Name: aws.String("CNAME-srv"), Description: aws.String("CNAME-srv"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeCname, TTL: aws.Int64(80), }}, }, NamespaceId: aws.String("private"), } // ALIAS type _, err = provider.CreateService(t.Context(), aws.String("private"), aws.String("ALIAS-srv"), &endpoint.Endpoint{ Labels: map[string]string{ endpoint.AWSSDDescriptionLabel: "ALIAS-srv", }, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 100, Targets: endpoint.Targets{"load-balancer.us-east-1.elb.amazonaws.com"}, }) assert.NoError(t, err) expectedServices["ALIAS-srv"] = &sdtypes.Service{ Name: aws.String("ALIAS-srv"), Description: aws.String("ALIAS-srv"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeA, TTL: aws.Int64(100), }}, }, NamespaceId: aws.String("private"), } testHelperAWSSDServicesMapsEqual(t, expectedServices, api.services["private"]) } func TestAWSSDProvider_CreateServiceDryRun(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: make(map[string]map[string]*sdtypes.Service), } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") provider.dryRun = true service, err := provider.CreateService(t.Context(), aws.String("private"), aws.String("A-srv"), &endpoint.Endpoint{ Labels: map[string]string{ endpoint.AWSSDDescriptionLabel: "A-srv", }, RecordType: endpoint.RecordTypeA, RecordTTL: 60, Targets: endpoint.Targets{"1.2.3.4"}, }) assert.NoError(t, err) assert.NotNil(t, service) assert.Equal(t, "dry-run-service", *service.Name) } func TestAWSSDProvider_CreateService_LabelNotSet(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: make(map[string]map[string]*sdtypes.Service), } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "owner-123") service, err := provider.CreateService(t.Context(), aws.String("private"), aws.String("A-srv"), &endpoint.Endpoint{ Labels: map[string]string{ "wrong-unsupported-label": "A-srv", }, RecordType: endpoint.RecordTypeA, RecordTTL: 60, Targets: endpoint.Targets{"1.2.3.4"}, }) assert.NoError(t, err) assert.NotNil(t, service) assert.Empty(t, *service.Description) } func TestAWSSDProvider_UpdateService(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "srv1": { Id: aws.String("srv1"), Name: aws.String("service1"), NamespaceId: aws.String("private"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyMultivalue, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeA, TTL: aws.Int64(60), }}, }, }, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: services, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") // update service with different TTL err := provider.UpdateService(t.Context(), services["private"]["srv1"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeA, RecordTTL: 100, }) assert.NoError(t, err) assert.Len(t, api.services["private"], 1) assert.Equal(t, int64(100), *api.services["private"]["srv1"].DnsConfig.DnsRecords[0].TTL) } func TestAWSSDProvider_UpdateService_DryRun(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "srv1": { Id: aws.String("srv1"), Name: aws.String("service1"), NamespaceId: aws.String("private"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyMultivalue, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeA, TTL: aws.Int64(60), }}, }, }, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: services, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") provider.dryRun = true // update service with different TTL err := provider.UpdateService(t.Context(), services["private"]["srv1"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeAAAA, RecordTTL: 100, }) assert.NoError(t, err) assert.Len(t, api.services["private"], 1) // records should not be updated assert.NotEqual(t, 100, api.services["private"]["srv1"].DnsConfig.DnsRecords[0].TTL) assert.NotEqual(t, endpoint.RecordTypeAAAA, api.services["private"]["srv1"].DnsConfig.DnsRecords[0].Type) } func TestAWSSDProvider_DeleteService(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "srv1": { Id: aws.String("srv1"), Description: aws.String("heritage=external-dns,external-dns/owner=owner-id"), Name: aws.String("service1"), NamespaceId: aws.String("private"), }, "srv2": { Id: aws.String("srv2"), Description: aws.String("heritage=external-dns,external-dns/owner=owner-id"), Name: aws.String("service2"), NamespaceId: aws.String("private"), }, "srv3": { Id: aws.String("srv3"), Description: aws.String("heritage=external-dns,external-dns/owner=owner-id,external-dns/resource=virtualservice/grpc-server/validate-grpc-server"), Name: aws.String("service3"), NamespaceId: aws.String("private"), }, "srv4": { Id: aws.String("srv4"), Description: nil, Name: aws.String("service4"), NamespaceId: aws.String("private"), }, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: services, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "owner-id") // delete first service err := provider.DeleteService(t.Context(), services["private"]["srv1"]) assert.NoError(t, err) assert.Len(t, api.services["private"], 3) // delete third service err = provider.DeleteService(t.Context(), services["private"]["srv3"]) assert.NoError(t, err) assert.Len(t, api.services["private"], 2) // delete service with no description err = provider.DeleteService(t.Context(), services["private"]["srv4"]) assert.NoError(t, err) expected := map[string]*sdtypes.Service{ "srv2": { Id: aws.String("srv2"), Description: aws.String("heritage=external-dns,external-dns/owner=owner-id"), Name: aws.String("service2"), NamespaceId: aws.String("private"), }, "srv4": { Id: aws.String("srv4"), Description: nil, Name: aws.String("service4"), NamespaceId: aws.String("private"), }, } assert.Equal(t, expected, api.services["private"]) } func TestAWSSDProvider_DeleteServiceEmptyDescription_Logging(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "srv1": { Id: aws.String("srv1"), Description: nil, Name: aws.String("service1"), NamespaceId: aws.String("private"), }, }, } logs := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) api := &AWSSDClientStub{ namespaces: namespaces, services: services, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "owner-id") // delete service err := provider.DeleteService(t.Context(), services["private"]["srv1"]) assert.NoError(t, err) assert.Len(t, api.services["private"], 1) logtest.TestHelperLogContainsWithLogLevel("Skipping service removal \"service1\" because owner id (service.Description) not set, when should be", log.DebugLevel, logs, t) } func TestAWSSDProvider_DeleteServiceDryRun(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "srv1": { Id: aws.String("srv1"), Description: aws.String("heritage=external-dns,external-dns/owner=owner-id"), Name: aws.String("service1"), NamespaceId: aws.String("private"), }, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: services, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "owner-id") provider.dryRun = true // delete first service err := provider.DeleteService(t.Context(), services["private"]["srv1"]) assert.NoError(t, err) assert.Len(t, api.services["private"], 1) } func TestAWSSDProvider_RegisterInstance(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "a-srv": { Id: aws.String("a-srv"), Name: aws.String("service1"), NamespaceId: aws.String("private"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeA, TTL: aws.Int64(60), }}, }, }, "cname-srv": { Id: aws.String("cname-srv"), Name: aws.String("service2"), NamespaceId: aws.String("private"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeCname, TTL: aws.Int64(60), }}, }, }, "alias-srv": { Id: aws.String("alias-srv"), Name: aws.String("service3"), NamespaceId: aws.String("private"), DnsConfig: &sdtypes.DnsConfig{ RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeA, TTL: aws.Int64(60), }}, }, }, "aaaa-srv": { Id: aws.String("aaaa-srv"), Name: aws.String("service4"), Description: aws.String("owner-id"), DnsConfig: &sdtypes.DnsConfig{ NamespaceId: aws.String("private"), RoutingPolicy: sdtypes.RoutingPolicyWeighted, DnsRecords: []sdtypes.DnsRecord{{ Type: sdtypes.RecordTypeAaaa, TTL: aws.Int64(100), }}, }, }, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: services, instances: make(map[string]map[string]*sdtypes.Instance), } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") expectedInstances := make(map[string]*sdtypes.Instance) // IPv4-based instance err := provider.RegisterInstance(t.Context(), services["private"]["a-srv"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeA, DNSName: "service1.private.com.", RecordTTL: 300, Targets: endpoint.Targets{"1.2.3.4", "1.2.3.5"}, }) assert.NoError(t, err) expectedInstances["1.2.3.4"] = &sdtypes.Instance{ Id: aws.String("1.2.3.4"), Attributes: map[string]string{ sdInstanceAttrIPV4: "1.2.3.4", }, } expectedInstances["1.2.3.5"] = &sdtypes.Instance{ Id: aws.String("1.2.3.5"), Attributes: map[string]string{ sdInstanceAttrIPV4: "1.2.3.5", }, } // AWS ELB instance (ALIAS) err = provider.RegisterInstance(t.Context(), services["private"]["alias-srv"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeCNAME, DNSName: "service1.private.com.", RecordTTL: 300, Targets: endpoint.Targets{"load-balancer.us-east-1.elb.amazonaws.com", "load-balancer.us-west-2.elb.amazonaws.com"}, }) assert.NoError(t, err) expectedInstances["load-balancer.us-east-1.elb.amazonaws.com"] = &sdtypes.Instance{ Id: aws.String("load-balancer.us-east-1.elb.amazonaws.com"), Attributes: map[string]string{ sdInstanceAttrAlias: "load-balancer.us-east-1.elb.amazonaws.com", }, } expectedInstances["load-balancer.us-west-2.elb.amazonaws.com"] = &sdtypes.Instance{ Id: aws.String("load-balancer.us-west-2.elb.amazonaws.com"), Attributes: map[string]string{ sdInstanceAttrAlias: "load-balancer.us-west-2.elb.amazonaws.com", }, } // AWS NLB instance (ALIAS) _ = provider.RegisterInstance(t.Context(), services["private"]["alias-srv"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeCNAME, DNSName: "service1.private.com.", RecordTTL: 300, Targets: endpoint.Targets{"load-balancer.elb.us-west-2.amazonaws.com"}, }) expectedInstances["load-balancer.elb.us-west-2.amazonaws.com"] = &sdtypes.Instance{ Id: aws.String("load-balancer.elb.us-west-2.amazonaws.com"), Attributes: map[string]string{ sdInstanceAttrAlias: "load-balancer.elb.us-west-2.amazonaws.com", }, } // CNAME instance _ = provider.RegisterInstance(t.Context(), services["private"]["cname-srv"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeCNAME, DNSName: "service2.private.com.", RecordTTL: 300, Targets: endpoint.Targets{"cname.target.com"}, }) expectedInstances["cname.target.com"] = &sdtypes.Instance{ Id: aws.String("cname.target.com"), Attributes: map[string]string{ sdInstanceAttrCname: "cname.target.com", }, } // IPv6-based instance provider.RegisterInstance(t.Context(), services["private"]["aaaa-srv"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeAAAA, DNSName: "service4.private.com.", RecordTTL: 300, Targets: endpoint.Targets{"0000:0000:0000:0000:abcd:abcd:abcd:abcd"}, }) expectedInstances["0000:0000:0000:0000:abcd:abcd:abcd:abcd"] = &sdtypes.Instance{ Id: aws.String("0000:0000:0000:0000:abcd:abcd:abcd:abcd"), Attributes: map[string]string{ sdInstanceAttrIPV6: "0000:0000:0000:0000:abcd:abcd:abcd:abcd", }, } // validate instances for _, srvInst := range api.instances { for id, inst := range srvInst { if !reflect.DeepEqual(*expectedInstances[id], *inst) { t.Errorf("Instances don't match, expected = %v, actual %v", *expectedInstances[id], *inst) } } } } func TestAWSSDProvider_DeregisterInstance(t *testing.T) { namespaces := map[string]*sdtypes.Namespace{ "private": { Id: aws.String("private"), Name: aws.String("private.com"), Type: sdtypes.NamespaceTypeDnsPrivate, }, } services := map[string]map[string]*sdtypes.Service{ "private": { "srv1": { Id: aws.String("srv1"), Name: aws.String("service1"), }, }, } instances := map[string]map[string]*sdtypes.Instance{ "srv1": { "1.2.3.4": { Id: aws.String("1.2.3.4"), Attributes: map[string]string{ sdInstanceAttrIPV4: "1.2.3.4", }, }, }, } api := &AWSSDClientStub{ namespaces: namespaces, services: services, instances: instances, } provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") _ = provider.DeregisterInstance(t.Context(), services["private"]["srv1"], endpoint.NewEndpoint("srv1.private.com.", endpoint.RecordTypeA, "1.2.3.4")) assert.Empty(t, instances["srv1"]) } func TestAWSSDProvider_awsTags(t *testing.T) { tests := []struct { Expectation []sdtypes.Tag Input map[string]string }{ { Expectation: []sdtypes.Tag{ { Key: aws.String("key1"), Value: aws.String("value1"), }, { Key: aws.String("key2"), Value: aws.String("value2"), }, }, Input: map[string]string{ "key1": "value1", "key2": "value2", }, }, { Expectation: []sdtypes.Tag{}, Input: map[string]string{}, }, { Expectation: []sdtypes.Tag{}, Input: nil, }, } for _, test := range tests { require.ElementsMatch(t, test.Expectation, awsTags(test.Input)) } } ================================================ FILE: provider/awssd/fixtures_test.go ================================================ /* 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 awssd import ( "context" "errors" "reflect" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/servicediscovery" "github.com/aws/aws-sdk-go-v2/service/servicediscovery/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" sd "github.com/aws/aws-sdk-go-v2/service/servicediscovery" sdtypes "github.com/aws/aws-sdk-go-v2/service/servicediscovery/types" ) var ( // Compile time checks for interface conformance _ AWSSDClient = &AWSSDClientStub{} ErrNamespaceNotFound = errors.New("namespace not found") ) type AWSSDClientStub struct { // map[namespace_id]namespace namespaces map[string]*types.Namespace // map[namespace_id] => map[service_id]instance services map[string]map[string]*types.Service // map[service_id] => map[inst_id]instance instances map[string]map[string]*types.Instance // []inst_id deregistered []string } func (s *AWSSDClientStub) CreateService(_ context.Context, input *servicediscovery.CreateServiceInput, _ ...func(*servicediscovery.Options)) (*servicediscovery.CreateServiceOutput, error) { srv := &types.Service{ Id: input.Name, DnsConfig: input.DnsConfig, Name: input.Name, Description: input.Description, CreateDate: aws.Time(time.Now()), CreatorRequestId: input.CreatorRequestId, } nsServices, ok := s.services[*input.NamespaceId] if !ok { nsServices = make(map[string]*types.Service) s.services[*input.NamespaceId] = nsServices } nsServices[*srv.Id] = srv return &servicediscovery.CreateServiceOutput{ Service: srv, }, nil } func (s *AWSSDClientStub) DeregisterInstance(_ context.Context, input *servicediscovery.DeregisterInstanceInput, _ ...func(options *servicediscovery.Options)) (*servicediscovery.DeregisterInstanceOutput, error) { serviceInstances := s.instances[*input.ServiceId] delete(serviceInstances, *input.InstanceId) s.deregistered = append(s.deregistered, *input.InstanceId) return &servicediscovery.DeregisterInstanceOutput{}, nil } func (s *AWSSDClientStub) GetService(_ context.Context, input *servicediscovery.GetServiceInput, _ ...func(options *servicediscovery.Options)) (*servicediscovery.GetServiceOutput, error) { for _, entry := range s.services { srv, ok := entry[*input.Id] if ok { return &servicediscovery.GetServiceOutput{ Service: srv, }, nil } } return nil, errors.New("service not found") } func (s *AWSSDClientStub) DiscoverInstances(_ context.Context, input *sd.DiscoverInstancesInput, _ ...func(options *sd.Options)) (*sd.DiscoverInstancesOutput, error) { instances := make([]sdtypes.HttpInstanceSummary, 0) var foundNs bool for _, ns := range s.namespaces { if *ns.Name == *input.NamespaceName { foundNs = true for _, srv := range s.services[*ns.Id] { if *srv.Name == *input.ServiceName { for _, inst := range s.instances[*srv.Id] { instances = append(instances, *instanceToHTTPInstanceSummary(inst)) } } } } } if !foundNs { return nil, ErrNamespaceNotFound } return &sd.DiscoverInstancesOutput{ Instances: instances, }, nil } func (s *AWSSDClientStub) ListNamespaces(_ context.Context, input *sd.ListNamespacesInput, _ ...func(options *sd.Options)) (*sd.ListNamespacesOutput, error) { namespaces := make([]sdtypes.NamespaceSummary, 0) for _, ns := range s.namespaces { if len(input.Filters) > 0 && input.Filters[0].Name == sdtypes.NamespaceFilterNameType { if ns.Type != sdtypes.NamespaceType(input.Filters[0].Values[0]) { // skip namespaces not matching filter continue } } namespaces = append(namespaces, *namespaceToNamespaceSummary(ns)) } return &sd.ListNamespacesOutput{ Namespaces: namespaces, }, nil } func (s *AWSSDClientStub) ListServices(_ context.Context, input *sd.ListServicesInput, _ ...func(options *sd.Options)) (*sd.ListServicesOutput, error) { services := make([]sdtypes.ServiceSummary, 0) // get namespace filter if len(input.Filters) == 0 || input.Filters[0].Name != sdtypes.ServiceFilterNameNamespaceId { return nil, errors.New("missing namespace filter") } nsID := input.Filters[0].Values[0] for _, srv := range s.services[nsID] { services = append(services, *serviceToServiceSummary(srv)) } return &sd.ListServicesOutput{ Services: services, }, nil } func (s *AWSSDClientStub) RegisterInstance(_ context.Context, input *sd.RegisterInstanceInput, _ ...func(options *sd.Options)) (*sd.RegisterInstanceOutput, error) { srvInstances, ok := s.instances[*input.ServiceId] if !ok { srvInstances = make(map[string]*sdtypes.Instance) s.instances[*input.ServiceId] = srvInstances } srvInstances[*input.InstanceId] = &sdtypes.Instance{ Id: input.InstanceId, Attributes: input.Attributes, CreatorRequestId: input.CreatorRequestId, } return &sd.RegisterInstanceOutput{}, nil } func (s *AWSSDClientStub) UpdateService(ctx context.Context, input *sd.UpdateServiceInput, _ ...func(options *sd.Options)) (*sd.UpdateServiceOutput, error) { out, err := s.GetService(ctx, &sd.GetServiceInput{Id: input.Id}) if err != nil { return nil, err } origSrv := out.Service updateSrv := input.Service origSrv.Description = updateSrv.Description origSrv.DnsConfig.DnsRecords = updateSrv.DnsConfig.DnsRecords return &sd.UpdateServiceOutput{}, nil } func (s *AWSSDClientStub) DeleteService(ctx context.Context, input *sd.DeleteServiceInput, _ ...func(options *sd.Options)) (*sd.DeleteServiceOutput, error) { out, err := s.GetService(ctx, &sd.GetServiceInput{Id: input.Id}) if err != nil { return nil, err } service := out.Service namespace := s.services[*service.NamespaceId] delete(namespace, *input.Id) return &sd.DeleteServiceOutput{}, nil } func newTestAWSSDProvider(api AWSSDClient, domainFilter *endpoint.DomainFilter, namespaceTypeFilter, ownerID string) *AWSSDProvider { return &AWSSDProvider{ client: api, dryRun: false, namespaceFilter: domainFilter, namespaceTypeFilter: newSdNamespaceFilter(namespaceTypeFilter), cleanEmptyService: true, ownerID: ownerID, } } func instanceToHTTPInstanceSummary(instance *sdtypes.Instance) *sdtypes.HttpInstanceSummary { if instance == nil { return nil } return &sdtypes.HttpInstanceSummary{ InstanceId: instance.Id, Attributes: instance.Attributes, } } func namespaceToNamespaceSummary(namespace *sdtypes.Namespace) *sdtypes.NamespaceSummary { if namespace == nil { return nil } return &sdtypes.NamespaceSummary{ Id: namespace.Id, Type: namespace.Type, Name: namespace.Name, Arn: namespace.Arn, } } func serviceToServiceSummary(service *sdtypes.Service) *sdtypes.ServiceSummary { if service == nil { return nil } return &sdtypes.ServiceSummary{ Arn: service.Arn, CreateDate: service.CreateDate, Description: service.Description, DnsConfig: service.DnsConfig, HealthCheckConfig: service.HealthCheckConfig, HealthCheckCustomConfig: service.HealthCheckCustomConfig, Id: service.Id, InstanceCount: service.InstanceCount, Name: service.Name, Type: service.Type, } } func testHelperAWSSDServicesMapsEqual(t *testing.T, expected map[string]*sdtypes.Service, services map[string]*sdtypes.Service) { require.Len(t, services, len(expected)) for _, srv := range services { testHelperAWSSDServicesEqual(t, expected[*srv.Name], srv) } } func testHelperAWSSDServicesEqual(t *testing.T, expected *sdtypes.Service, srv *sdtypes.Service) { assert.Equal(t, *expected.Description, *srv.Description) assert.Equal(t, *expected.Name, *srv.Name) assert.True(t, reflect.DeepEqual(*expected.DnsConfig, *srv.DnsConfig)) } ================================================ FILE: provider/azure/azure.go ================================================ /* Copyright 2017 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. */ //nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go package azure import ( "context" "fmt" "strings" "time" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/provider/blueprint" azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( defaultTTL = 300 ) // ZonesClient is an interface of dns.ZoneClient that can be stubbed for testing. type ZonesClient interface { NewListByResourceGroupPager(resourceGroupName string, options *dns.ZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[dns.ZonesClientListByResourceGroupResponse] } // RecordSetsClient is an interface of dns.RecordSetsClient that can be stubbed for testing. type RecordSetsClient interface { NewListAllByDNSZonePager(resourceGroupName string, zoneName string, options *dns.RecordSetsClientListAllByDNSZoneOptions) *azcoreruntime.Pager[dns.RecordSetsClientListAllByDNSZoneResponse] Delete(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, options *dns.RecordSetsClientDeleteOptions) (dns.RecordSetsClientDeleteResponse, error) CreateOrUpdate(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, parameters dns.RecordSet, options *dns.RecordSetsClientCreateOrUpdateOptions) (dns.RecordSetsClientCreateOrUpdateResponse, error) } // AzureProvider implements the DNS provider for Microsoft's Azure cloud platform. type AzureProvider struct { provider.BaseProvider domainFilter *endpoint.DomainFilter zoneNameFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter dryRun bool resourceGroup string userAssignedIdentityClientID string activeDirectoryAuthorityHost string zonesClient ZonesClient zonesCache *blueprint.ZoneCache[[]dns.Zone] recordSetsClient RecordSetsClient maxRetriesCount int } // New creates an Azure DNS provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider( cfg.AzureConfigFile, domainFilter, endpoint.NewDomainFilter(cfg.ZoneNameFilter), provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.AzureSubscriptionID, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.AzureActiveDirectoryAuthorityHost, cfg.AzureZonesCacheDuration, cfg.AzureMaxRetriesCount, cfg.DryRun, ) } // newProvider creates a new Azure provider. // // Returns the provider or an error if a provider could not be created. func newProvider(configFile string, domainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost string, zonesCacheDuration time.Duration, maxRetriesCount int, dryRun bool) (*AzureProvider, error) { cfg, err := getConfig(configFile, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost) if err != nil { return nil, fmt.Errorf("failed to read Azure config file '%s': %w", configFile, err) } cred, clientOpts, err := getCredentials(*cfg, maxRetriesCount) if err != nil { return nil, fmt.Errorf("failed to get credentials: %w", err) } zonesClient, err := dns.NewZonesClient(cfg.SubscriptionID, cred, clientOpts) if err != nil { return nil, err } recordSetsClient, err := dns.NewRecordSetsClient(cfg.SubscriptionID, cred, clientOpts) if err != nil { return nil, err } return &AzureProvider{ domainFilter: domainFilter, zoneNameFilter: zoneNameFilter, zoneIDFilter: zoneIDFilter, dryRun: dryRun, resourceGroup: cfg.ResourceGroup, userAssignedIdentityClientID: cfg.UserAssignedIdentityID, activeDirectoryAuthorityHost: cfg.ActiveDirectoryAuthorityHost, zonesClient: zonesClient, zonesCache: blueprint.NewZoneCache[[]dns.Zone](zonesCacheDuration), recordSetsClient: recordSetsClient, maxRetriesCount: maxRetriesCount, }, nil } // Records gets the current records. // // Returns the current records or an error if the operation failed. func (p *AzureProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.zones(ctx) if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, 0) for _, zone := range zones { pager := p.recordSetsClient.NewListAllByDNSZonePager(p.resourceGroup, *zone.Name, &dns.RecordSetsClientListAllByDNSZoneOptions{Top: nil}) for pager.More() { nextResult, err := pager.NextPage(ctx) if err != nil { return nil, provider.NewSoftErrorf("failed to fetch dns records: %w", err) } for _, recordSet := range nextResult.Value { if recordSet.Name == nil || recordSet.Type == nil { log.Error("Skipping invalid record set with nil name or type.") continue } recordType := strings.TrimPrefix(*recordSet.Type, "Microsoft.Network/dnszones/") if !p.SupportedRecordType(recordType) { continue } name := formatAzureDNSName(*recordSet.Name, *zone.Name) if len(p.zoneNameFilter.Filters) > 0 && !p.domainFilter.Match(name) { log.Debugf("Skipping return of record %s because it was filtered out by the specified --domain-filter", name) continue } targets := extractAzureTargets(recordSet) if len(targets) == 0 { log.Debugf("Failed to extract targets for '%s' with type '%s'.", name, recordType) continue } var ttl endpoint.TTL if recordSet.Properties.TTL != nil { ttl = endpoint.TTL(*recordSet.Properties.TTL) } ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...) log.Debugf( "Found %s record for '%s' with target '%s'.", ep.RecordType, ep.DNSName, ep.Targets, ) endpoints = append(endpoints, ep) } } } return endpoints, nil } // ApplyChanges applies the given changes. // // Returns nil if the operation was successful or an error if the operation failed. func (p *AzureProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { zones, err := p.zones(ctx) if err != nil { return err } deleted, updated := p.mapChanges(zones, changes) p.deleteRecords(ctx, deleted) p.updateRecords(ctx, updated) return nil } func (p *AzureProvider) zones(ctx context.Context) ([]dns.Zone, error) { if !p.zonesCache.Expired() { cachedZones := p.zonesCache.Get() log.Debugf("Using cached Azure DNS zones for resource group: %s zone count: %d.", p.resourceGroup, len(cachedZones)) return cachedZones, nil } log.Debugf("Retrieving Azure DNS zones for resource group: %s.", p.resourceGroup) var zones []dns.Zone pager := p.zonesClient.NewListByResourceGroupPager(p.resourceGroup, &dns.ZonesClientListByResourceGroupOptions{Top: nil}) for pager.More() { nextResult, err := pager.NextPage(ctx) if err != nil { return nil, err } for _, zone := range nextResult.Value { if zone.Name != nil && p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.ID) { zones = append(zones, *zone) } else if zone.Name != nil && len(p.zoneNameFilter.Filters) > 0 && p.zoneNameFilter.Match(*zone.Name) { // Handle zoneNameFilter zones = append(zones, *zone) } } } p.zonesCache.Reset(zones) return zones, nil } func (p *AzureProvider) SupportedRecordType(recordType string) bool { switch recordType { case "MX": return true default: return provider.SupportedRecordType(recordType) } } type azureChangeMap map[string][]*endpoint.Endpoint func (p *AzureProvider) mapChanges(zones []dns.Zone, changes *plan.Changes) (azureChangeMap, azureChangeMap) { ignored := map[string]bool{} deleted := azureChangeMap{} updated := azureChangeMap{} zoneNameIDMapper := provider.ZoneIDName{} for _, z := range zones { if z.Name != nil { zoneNameIDMapper.Add(*z.Name, *z.Name) } } mapChange := func(changeMap azureChangeMap, change *endpoint.Endpoint) { zone, _ := zoneNameIDMapper.FindZone(change.DNSName) if zone == "" { if _, ok := ignored[change.DNSName]; !ok { ignored[change.DNSName] = true log.Infof("Ignoring changes to '%s' because a suitable Azure DNS zone was not found.", change.DNSName) } return } // Ensure the record type is suitable changeMap[zone] = append(changeMap[zone], change) } for _, change := range changes.Delete { mapChange(deleted, change) } for _, change := range changes.Create { mapChange(updated, change) } for _, change := range changes.UpdateNew { mapChange(updated, change) } return deleted, updated } func (p *AzureProvider) deleteRecords(ctx context.Context, deleted azureChangeMap) { // Delete records first for zone, endpoints := range deleted { for _, ep := range endpoints { name := p.recordSetNameForZone(zone, ep) if !p.domainFilter.Match(ep.DNSName) { log.Debugf("Skipping deletion of record %s because it was filtered out by the specified --domain-filter", ep.DNSName) continue } if p.dryRun { log.Infof("Would delete %s record named '%s' for Azure DNS zone '%s'.", ep.RecordType, name, zone) } else { log.Infof("Deleting %s record named '%s' for Azure DNS zone '%s'.", ep.RecordType, name, zone) if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, name, dns.RecordType(ep.RecordType), nil); err != nil { log.Errorf( "Failed to delete %s record named '%s' for Azure DNS zone '%s': %v", ep.RecordType, name, zone, err, ) } } } } } func (p *AzureProvider) updateRecords(ctx context.Context, updated azureChangeMap) { for zone, endpoints := range updated { for _, ep := range endpoints { name := p.recordSetNameForZone(zone, ep) if !p.domainFilter.Match(ep.DNSName) { log.Debugf("Skipping update of record %s because it was filtered out by the specified --domain-filter", ep.DNSName) continue } if p.dryRun { log.Infof( "Would update %s record named '%s' to '%s' for Azure DNS zone '%s'.", ep.RecordType, name, ep.Targets, zone, ) continue } log.Infof( "Updating %s record named '%s' to '%s' for Azure DNS zone '%s'.", ep.RecordType, name, ep.Targets, zone, ) recordSet, err := p.newRecordSet(ep) if err == nil { _, err = p.recordSetsClient.CreateOrUpdate( ctx, p.resourceGroup, zone, name, dns.RecordType(ep.RecordType), recordSet, nil, ) } if err != nil { log.Errorf( "Failed to update %s record named '%s' to '%s' for DNS zone '%s': %v", ep.RecordType, name, ep.Targets, zone, err, ) } } } } func (p *AzureProvider) recordSetNameForZone(zone string, endpoint *endpoint.Endpoint) string { // Remove the zone from the record set name := endpoint.DNSName name = name[:len(name)-len(zone)] name = strings.TrimSuffix(name, ".") // For root, use @ if name == "" { return "@" } return name } func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet, error) { var ttl int64 = defaultTTL if endpoint.RecordTTL.IsConfigured() { ttl = int64(endpoint.RecordTTL) } switch dns.RecordType(endpoint.RecordType) { case dns.RecordTypeA: aRecords := make([]*dns.ARecord, len(endpoint.Targets)) for i, target := range endpoint.Targets { aRecords[i] = &dns.ARecord{ IPv4Address: to.Ptr(target), } } return dns.RecordSet{ Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), ARecords: aRecords, }, }, nil case dns.RecordTypeAAAA: aaaaRecords := make([]*dns.AaaaRecord, len(endpoint.Targets)) for i, target := range endpoint.Targets { aaaaRecords[i] = &dns.AaaaRecord{ IPv6Address: to.Ptr(target), } } return dns.RecordSet{ Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), AaaaRecords: aaaaRecords, }, }, nil case dns.RecordTypeCNAME: return dns.RecordSet{ Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), CnameRecord: &dns.CnameRecord{ Cname: to.Ptr(endpoint.Targets[0]), }, }, }, nil case dns.RecordTypeMX: mxRecords := make([]*dns.MxRecord, len(endpoint.Targets)) for i, target := range endpoint.Targets { mxRecord, err := parseMxTarget[dns.MxRecord](target) if err != nil { return dns.RecordSet{}, err } mxRecords[i] = &mxRecord } return dns.RecordSet{ Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), MxRecords: mxRecords, }, }, nil case dns.RecordTypeNS: nsRecords := make([]*dns.NsRecord, len(endpoint.Targets)) for i, target := range endpoint.Targets { nsRecords[i] = &dns.NsRecord{ Nsdname: to.Ptr(target), } } return dns.RecordSet{ Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), NsRecords: nsRecords, }, }, nil case dns.RecordTypeTXT: return dns.RecordSet{ Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), TxtRecords: []*dns.TxtRecord{ { Value: []*string{ &endpoint.Targets[0], }, }, }, }, }, nil } return dns.RecordSet{}, fmt.Errorf("unsupported record type '%s'", endpoint.RecordType) } // Helper function (shared with test code) func formatAzureDNSName(recordName, zoneName string) string { if recordName == "@" { return zoneName } return fmt.Sprintf("%s.%s", recordName, zoneName) } // Helper function (shared with text code) func extractAzureTargets(recordSet *dns.RecordSet) []string { properties := recordSet.Properties if properties == nil { return []string{} } // Check for A records aRecords := properties.ARecords if len(aRecords) > 0 && (aRecords)[0].IPv4Address != nil { targets := make([]string, len(aRecords)) for i, aRecord := range aRecords { targets[i] = *aRecord.IPv4Address } return targets } // Check for AAAA records aaaaRecords := properties.AaaaRecords if len(aaaaRecords) > 0 && (aaaaRecords)[0].IPv6Address != nil { targets := make([]string, len(aaaaRecords)) for i, aaaaRecord := range aaaaRecords { targets[i] = *aaaaRecord.IPv6Address } return targets } // Check for CNAME records cnameRecord := properties.CnameRecord if cnameRecord != nil && cnameRecord.Cname != nil { return []string{*cnameRecord.Cname} } // Check for MX records mxRecords := properties.MxRecords if len(mxRecords) > 0 && (mxRecords)[0].Exchange != nil { targets := make([]string, len(mxRecords)) for i, mxRecord := range mxRecords { targets[i] = fmt.Sprintf("%d %s", *mxRecord.Preference, *mxRecord.Exchange) } return targets } // Check for NS records nsRecords := properties.NsRecords if len(nsRecords) > 0 && (nsRecords)[0].Nsdname != nil { targets := make([]string, len(nsRecords)) for i, nsRecord := range nsRecords { targets[i] = *nsRecord.Nsdname } return targets } // Check for TXT records txtRecords := properties.TxtRecords if len(txtRecords) > 0 && (txtRecords)[0].Value != nil { values := (txtRecords)[0].Value if len(values) > 0 { return []string{*(values)[0]} } } return []string{} } ================================================ FILE: provider/azure/azure_private_dns.go ================================================ /* Copyright 2017 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. */ //nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go package azure import ( "context" "fmt" "strings" "time" azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/provider/blueprint" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) // PrivateZonesClient is an interface of privatedns.PrivateZoneClient that can be stubbed for testing. type PrivateZonesClient interface { NewListByResourceGroupPager(resourceGroupName string, options *privatedns.PrivateZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[privatedns.PrivateZonesClientListByResourceGroupResponse] } // PrivateRecordSetsClient is an interface of privatedns.RecordSetsClient that can be stubbed for testing. type PrivateRecordSetsClient interface { NewListPager(resourceGroupName string, privateZoneName string, options *privatedns.RecordSetsClientListOptions) *azcoreruntime.Pager[privatedns.RecordSetsClientListResponse] Delete(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, options *privatedns.RecordSetsClientDeleteOptions) (privatedns.RecordSetsClientDeleteResponse, error) CreateOrUpdate(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, parameters privatedns.RecordSet, options *privatedns.RecordSetsClientCreateOrUpdateOptions) (privatedns.RecordSetsClientCreateOrUpdateResponse, error) } // AzurePrivateDNSProvider implements the DNS provider for Microsoft's Azure Private DNS service type AzurePrivateDNSProvider struct { provider.BaseProvider domainFilter *endpoint.DomainFilter zoneNameFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter dryRun bool resourceGroup string userAssignedIdentityClientID string activeDirectoryAuthorityHost string zonesClient PrivateZonesClient zonesCache *blueprint.ZoneCache[[]privatedns.PrivateZone] recordSetsClient PrivateRecordSetsClient maxRetriesCount int } // newPrivateDNSProvider creates a new Azure Private DNS provider. // // Returns the provider or an error if a provider could not be created. func newPrivateDNSProvider(configFile string, domainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost string, zonesCacheDuration time.Duration, maxRetriesCount int, dryRun bool) (*AzurePrivateDNSProvider, error) { cfg, err := getConfig(configFile, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost) if err != nil { return nil, fmt.Errorf("failed to read Azure config file '%s': %w", configFile, err) } cred, clientOpts, err := getCredentials(*cfg, maxRetriesCount) if err != nil { return nil, fmt.Errorf("failed to get credentials: %w", err) } zonesClient, err := privatedns.NewPrivateZonesClient(cfg.SubscriptionID, cred, clientOpts) if err != nil { return nil, err } recordSetsClient, err := privatedns.NewRecordSetsClient(cfg.SubscriptionID, cred, clientOpts) if err != nil { return nil, err } return &AzurePrivateDNSProvider{ domainFilter: domainFilter, zoneNameFilter: zoneNameFilter, zoneIDFilter: zoneIDFilter, dryRun: dryRun, resourceGroup: cfg.ResourceGroup, userAssignedIdentityClientID: cfg.UserAssignedIdentityID, activeDirectoryAuthorityHost: cfg.ActiveDirectoryAuthorityHost, zonesClient: zonesClient, zonesCache: blueprint.NewZoneCache[[]privatedns.PrivateZone](zonesCacheDuration), recordSetsClient: recordSetsClient, maxRetriesCount: maxRetriesCount, }, nil } // NewPrivate creates an Azure Private DNS provider from the given configuration. func NewPrivate(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newPrivateDNSProvider( cfg.AzureConfigFile, domainFilter, endpoint.NewDomainFilter(cfg.ZoneNameFilter), provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.AzureSubscriptionID, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.AzureActiveDirectoryAuthorityHost, cfg.AzureZonesCacheDuration, cfg.AzureMaxRetriesCount, cfg.DryRun, ) } // Records gets the current records. // // Returns the current records or an error if the operation failed. func (p *AzurePrivateDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.zones(ctx) if err != nil { return nil, err } log.Debugf("Retrieving Azure Private DNS Records for resource group '%s'", p.resourceGroup) endpoints := make([]*endpoint.Endpoint, 0) for _, zone := range zones { pager := p.recordSetsClient.NewListPager(p.resourceGroup, *zone.Name, &privatedns.RecordSetsClientListOptions{Top: nil}) for pager.More() { nextResult, err := pager.NextPage(ctx) if err != nil { return nil, provider.NewSoftErrorf("failed to fetch dns records: %v", err) } for _, recordSet := range nextResult.Value { var recordType string if recordSet.Type == nil { log.Debugf("Skipping invalid record set with missing type.") continue } recordType = strings.TrimPrefix(*recordSet.Type, "Microsoft.Network/privateDnsZones/") var name string if recordSet.Name == nil { log.Debugf("Skipping invalid record set with missing name.") continue } name = formatAzureDNSName(*recordSet.Name, *zone.Name) if len(p.zoneNameFilter.Filters) > 0 && !p.domainFilter.Match(name) { log.Debugf("Skipping return of record %s because it was filtered out by the specified --domain-filter", name) continue } targets := extractAzurePrivateDNSTargets(recordSet) if len(targets) == 0 { log.Debugf("Failed to extract targets for '%s' with type '%s'.", name, recordType) continue } var ttl endpoint.TTL if recordSet.Properties.TTL != nil { ttl = endpoint.TTL(*recordSet.Properties.TTL) } ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...) log.Debugf( "Found %s record for '%s' with target '%s'.", ep.RecordType, ep.DNSName, ep.Targets, ) endpoints = append(endpoints, ep) } } } log.Debugf("Returning %d Azure Private DNS Records for resource group '%s'", len(endpoints), p.resourceGroup) return endpoints, nil } // ApplyChanges applies the given changes. // // Returns nil if the operation was successful or an error if the operation failed. func (p *AzurePrivateDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { log.Debugf("Received %d changes to process", len(changes.Create)+len(changes.Delete)+len(changes.UpdateNew)+len(changes.UpdateOld)) zones, err := p.zones(ctx) if err != nil { return err } deleted, updated := p.mapChanges(zones, changes) p.deleteRecords(ctx, deleted) p.updateRecords(ctx, updated) return nil } func (p *AzurePrivateDNSProvider) zones(ctx context.Context) ([]privatedns.PrivateZone, error) { if !p.zonesCache.Expired() { cachedZones := p.zonesCache.Get() log.Debugf("Using cached Azure Private DNS zones for resource group: %s zone count: %d.", p.resourceGroup, len(cachedZones)) return cachedZones, nil } log.Debugf("Retrieving Azure Private DNS zones for resource group: %s.", p.resourceGroup) var zones []privatedns.PrivateZone pager := p.zonesClient.NewListByResourceGroupPager(p.resourceGroup, &privatedns.PrivateZonesClientListByResourceGroupOptions{Top: nil}) for pager.More() { nextResult, err := pager.NextPage(ctx) if err != nil { return nil, err } for _, zone := range nextResult.Value { if zone.Name != nil && p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.ID) { zones = append(zones, *zone) } else if zone.Name != nil && len(p.zoneNameFilter.Filters) > 0 && p.zoneNameFilter.Match(*zone.Name) { // Handle zoneNameFilter zones = append(zones, *zone) } } } p.zonesCache.Reset(zones) return zones, nil } type azurePrivateDNSChangeMap map[string][]*endpoint.Endpoint func (p *AzurePrivateDNSProvider) mapChanges(zones []privatedns.PrivateZone, changes *plan.Changes) (azurePrivateDNSChangeMap, azurePrivateDNSChangeMap) { ignored := map[string]bool{} deleted := azurePrivateDNSChangeMap{} updated := azurePrivateDNSChangeMap{} zoneNameIDMapper := provider.ZoneIDName{} for _, z := range zones { if z.Name != nil { zoneNameIDMapper.Add(*z.Name, *z.Name) } } mapChange := func(changeMap azurePrivateDNSChangeMap, change *endpoint.Endpoint) { zone, _ := zoneNameIDMapper.FindZone(change.DNSName) if zone == "" { if _, ok := ignored[change.DNSName]; !ok { ignored[change.DNSName] = true log.Infof("Ignoring changes to '%s' because a suitable Azure Private DNS zone was not found.", change.DNSName) } return } // Ensure the record type is suitable changeMap[zone] = append(changeMap[zone], change) } for _, change := range changes.Delete { mapChange(deleted, change) } for _, change := range changes.Create { mapChange(updated, change) } for _, change := range changes.UpdateNew { mapChange(updated, change) } return deleted, updated } func (p *AzurePrivateDNSProvider) deleteRecords(ctx context.Context, deleted azurePrivateDNSChangeMap) { log.Debugf("Records to be deleted: %d", len(deleted)) // Delete records first for zone, endpoints := range deleted { for _, ep := range endpoints { name := p.recordSetNameForZone(zone, ep) if !p.domainFilter.Match(ep.DNSName) { log.Debugf("Skipping deletion of record %s because it was filtered out by the specified --domain-filter", ep.DNSName) continue } if p.dryRun { log.Infof("Would delete %s record named '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, zone) } else { log.Infof("Deleting %s record named '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, zone) if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, privatedns.RecordType(ep.RecordType), name, nil); err != nil { log.Errorf( "Failed to delete %s record named '%s' for Azure Private DNS zone '%s': %v", ep.RecordType, name, zone, err, ) } } } } } func (p *AzurePrivateDNSProvider) updateRecords(ctx context.Context, updated azurePrivateDNSChangeMap) { log.Debugf("Records to be updated: %d", len(updated)) for zone, endpoints := range updated { for _, ep := range endpoints { name := p.recordSetNameForZone(zone, ep) if !p.domainFilter.Match(ep.DNSName) { log.Debugf("Skipping update of record %s because it was filtered out by the specified --domain-filter", ep.DNSName) continue } if p.dryRun { log.Infof( "Would update %s record named '%s' to '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, ep.Targets, zone, ) continue } log.Infof( "Updating %s record named '%s' to '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, ep.Targets, zone, ) recordSet, err := p.newRecordSet(ep) if err == nil { _, err = p.recordSetsClient.CreateOrUpdate( ctx, p.resourceGroup, zone, privatedns.RecordType(ep.RecordType), name, recordSet, nil, ) } if err != nil { log.Errorf( "Failed to update %s record named '%s' to '%s' for Azure Private DNS zone '%s': %v", ep.RecordType, name, ep.Targets, zone, err, ) } } } } func (p *AzurePrivateDNSProvider) recordSetNameForZone(zone string, endpoint *endpoint.Endpoint) string { // Remove the zone from the record set name := endpoint.DNSName name = name[:len(name)-len(zone)] name = strings.TrimSuffix(name, ".") // For root, use @ if name == "" { return "@" } return name } func (p *AzurePrivateDNSProvider) newRecordSet(endpoint *endpoint.Endpoint) (privatedns.RecordSet, error) { var ttl int64 = defaultTTL if endpoint.RecordTTL.IsConfigured() { ttl = int64(endpoint.RecordTTL) } switch privatedns.RecordType(endpoint.RecordType) { case privatedns.RecordTypeA: aRecords := make([]*privatedns.ARecord, len(endpoint.Targets)) for i, target := range endpoint.Targets { aRecords[i] = &privatedns.ARecord{ IPv4Address: to.Ptr(target), } } return privatedns.RecordSet{ Properties: &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), ARecords: aRecords, }, }, nil case privatedns.RecordTypeAAAA: aaaaRecords := make([]*privatedns.AaaaRecord, len(endpoint.Targets)) for i, target := range endpoint.Targets { aaaaRecords[i] = &privatedns.AaaaRecord{ IPv6Address: to.Ptr(target), } } return privatedns.RecordSet{ Properties: &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), AaaaRecords: aaaaRecords, }, }, nil case privatedns.RecordTypeCNAME: return privatedns.RecordSet{ Properties: &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), CnameRecord: &privatedns.CnameRecord{ Cname: to.Ptr(endpoint.Targets[0]), }, }, }, nil case privatedns.RecordTypeMX: mxRecords := make([]*privatedns.MxRecord, len(endpoint.Targets)) for i, target := range endpoint.Targets { mxRecord, err := parseMxTarget[privatedns.MxRecord](target) if err != nil { return privatedns.RecordSet{}, err } mxRecords[i] = &mxRecord } return privatedns.RecordSet{ Properties: &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), MxRecords: mxRecords, }, }, nil case privatedns.RecordTypeTXT: return privatedns.RecordSet{ Properties: &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), TxtRecords: []*privatedns.TxtRecord{ { Value: []*string{ &endpoint.Targets[0], }, }, }, }, }, nil } return privatedns.RecordSet{}, fmt.Errorf("unsupported record type '%s'", endpoint.RecordType) } // Helper function (shared with test code) func extractAzurePrivateDNSTargets(recordSet *privatedns.RecordSet) []string { properties := recordSet.Properties if properties == nil { return []string{} } // Check for A records aRecords := properties.ARecords if len(aRecords) > 0 && (aRecords)[0].IPv4Address != nil { targets := make([]string, len(aRecords)) for i, aRecord := range aRecords { targets[i] = *aRecord.IPv4Address } return targets } // Check for AAAA records aaaaRecords := properties.AaaaRecords if len(aaaaRecords) > 0 && (aaaaRecords)[0].IPv6Address != nil { targets := make([]string, len(aaaaRecords)) for i, aaaaRecord := range aaaaRecords { targets[i] = *aaaaRecord.IPv6Address } return targets } // Check for CNAME records cnameRecord := properties.CnameRecord if cnameRecord != nil && cnameRecord.Cname != nil { return []string{*cnameRecord.Cname} } // Check for MX records mxRecords := properties.MxRecords if len(mxRecords) > 0 && (mxRecords)[0].Exchange != nil { targets := make([]string, len(mxRecords)) for i, mxRecord := range mxRecords { targets[i] = fmt.Sprintf("%d %s", *mxRecord.Preference, *mxRecord.Exchange) } return targets } // Check for TXT records txtRecords := properties.TxtRecords if len(txtRecords) > 0 && (txtRecords)[0].Value != nil { values := (txtRecords)[0].Value if len(values) > 0 { return []string{*(values)[0]} } } return []string{} } ================================================ FILE: provider/azure/azure_privatedns_test.go ================================================ /* Copyright 2017 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 azure import ( "context" "testing" azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "sigs.k8s.io/external-dns/provider/blueprint" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( recordTTL = 300 ) // mockPrivateZonesClient implements the methods of the Azure Private DNS Zones Client which are used in the Azure Private DNS Provider // and returns static results which are defined per test type mockPrivateZonesClient struct { pagingHandler azcoreruntime.PagingHandler[privatedns.PrivateZonesClientListByResourceGroupResponse] } func newMockPrivateZonesClient(zones []*privatedns.PrivateZone) mockPrivateZonesClient { pagingHandler := azcoreruntime.PagingHandler[privatedns.PrivateZonesClientListByResourceGroupResponse]{ More: func(_ privatedns.PrivateZonesClientListByResourceGroupResponse) bool { return false }, Fetcher: func(context.Context, *privatedns.PrivateZonesClientListByResourceGroupResponse) (privatedns.PrivateZonesClientListByResourceGroupResponse, error) { return privatedns.PrivateZonesClientListByResourceGroupResponse{ PrivateZoneListResult: privatedns.PrivateZoneListResult{ Value: zones, }, }, nil }, } return mockPrivateZonesClient{ pagingHandler: pagingHandler, } } func (client *mockPrivateZonesClient) NewListByResourceGroupPager(_ string, _ *privatedns.PrivateZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[privatedns.PrivateZonesClientListByResourceGroupResponse] { return azcoreruntime.NewPager(client.pagingHandler) } // mockPrivateRecordSetsClient implements the methods of the Azure Private DNS RecordSet Client which are used in the Azure Private DNS Provider // and returns static results which are defined per test type mockPrivateRecordSetsClient struct { pagingHandler azcoreruntime.PagingHandler[privatedns.RecordSetsClientListResponse] deletedEndpoints []*endpoint.Endpoint updatedEndpoints []*endpoint.Endpoint } func newMockPrivateRecordSectsClient(recordSets []*privatedns.RecordSet) mockPrivateRecordSetsClient { pagingHandler := azcoreruntime.PagingHandler[privatedns.RecordSetsClientListResponse]{ More: func(_ privatedns.RecordSetsClientListResponse) bool { return false }, Fetcher: func(context.Context, *privatedns.RecordSetsClientListResponse) (privatedns.RecordSetsClientListResponse, error) { return privatedns.RecordSetsClientListResponse{ RecordSetListResult: privatedns.RecordSetListResult{ Value: recordSets, }, }, nil }, } return mockPrivateRecordSetsClient{ pagingHandler: pagingHandler, } } func (client *mockPrivateRecordSetsClient) NewListPager(_ string, _ string, _ *privatedns.RecordSetsClientListOptions) *azcoreruntime.Pager[privatedns.RecordSetsClientListResponse] { return azcoreruntime.NewPager(client.pagingHandler) } func (client *mockPrivateRecordSetsClient) Delete(_ context.Context, _ string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, _ *privatedns.RecordSetsClientDeleteOptions) (privatedns.RecordSetsClientDeleteResponse, error) { client.deletedEndpoints = append( client.deletedEndpoints, endpoint.NewEndpoint( formatAzureDNSName(relativeRecordSetName, privateZoneName), string(recordType), "", ), ) return privatedns.RecordSetsClientDeleteResponse{}, nil } func (client *mockPrivateRecordSetsClient) CreateOrUpdate(_ context.Context, _ string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, parameters privatedns.RecordSet, _ *privatedns.RecordSetsClientCreateOrUpdateOptions) (privatedns.RecordSetsClientCreateOrUpdateResponse, error) { var ttl endpoint.TTL if parameters.Properties.TTL != nil { ttl = endpoint.TTL(*parameters.Properties.TTL) } client.updatedEndpoints = append( client.updatedEndpoints, endpoint.NewEndpointWithTTL( formatAzureDNSName(relativeRecordSetName, privateZoneName), string(recordType), ttl, extractAzurePrivateDNSTargets(¶meters)..., ), ) return privatedns.RecordSetsClientCreateOrUpdateResponse{}, nil } func createMockPrivateZone(zone string, id string) *privatedns.PrivateZone { return &privatedns.PrivateZone{ ID: to.Ptr(id), Name: to.Ptr(zone), } } func privateARecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { aRecords := make([]*privatedns.ARecord, len(values)) for i, value := range values { aRecords[i] = &privatedns.ARecord{ IPv4Address: to.Ptr(value), } } return &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), ARecords: aRecords, } } func privateAAAARecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { aaaaRecords := make([]*privatedns.AaaaRecord, len(values)) for i, value := range values { aaaaRecords[i] = &privatedns.AaaaRecord{ IPv6Address: to.Ptr(value), } } return &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), AaaaRecords: aaaaRecords, } } func privateCNameRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { return &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), CnameRecord: &privatedns.CnameRecord{ Cname: to.Ptr(values[0]), }, } } func privateMXRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { mxRecords := make([]*privatedns.MxRecord, len(values)) for i, target := range values { mxRecord, _ := parseMxTarget[privatedns.MxRecord](target) mxRecords[i] = &mxRecord } return &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), MxRecords: mxRecords, } } func privateTxtRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { return &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), TxtRecords: []*privatedns.TxtRecord{ { Value: []*string{&values[0]}, }, }, } } func privateOthersRecordSetPropertiesGetter(_ []string, ttl int64) *privatedns.RecordSetProperties { return &privatedns.RecordSetProperties{ TTL: to.Ptr(ttl), } } func createPrivateMockRecordSet(recordType string, values ...string) *privatedns.RecordSet { return createPrivateMockRecordSetMultiWithTTL("@", recordType, 0, values...) } func createPrivateMockRecordSetWithNameAndTTL(name, recordType, value string, ttl int64) *privatedns.RecordSet { return createPrivateMockRecordSetMultiWithTTL(name, recordType, ttl, value) } func createPrivateMockRecordSetMultiWithTTL(name, recordType string, ttl int64, values ...string) *privatedns.RecordSet { var getterFunc func(values []string, ttl int64) *privatedns.RecordSetProperties switch recordType { case endpoint.RecordTypeA: getterFunc = privateARecordSetPropertiesGetter case endpoint.RecordTypeAAAA: getterFunc = privateAAAARecordSetPropertiesGetter case endpoint.RecordTypeCNAME: getterFunc = privateCNameRecordSetPropertiesGetter case endpoint.RecordTypeMX: getterFunc = privateMXRecordSetPropertiesGetter case endpoint.RecordTypeTXT: getterFunc = privateTxtRecordSetPropertiesGetter default: getterFunc = privateOthersRecordSetPropertiesGetter } return &privatedns.RecordSet{ Name: to.Ptr(name), Type: to.Ptr("Microsoft.Network/privateDnsZones/" + recordType), Properties: getterFunc(values, ttl), } } // newMockedAzurePrivateDNSProvider creates an AzureProvider comprising the mocked clients for zones and recordsets func newMockedAzurePrivateDNSProvider(domainFilter *endpoint.DomainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, zones []*privatedns.PrivateZone, recordSets []*privatedns.RecordSet, maxRetriesCount int) *AzurePrivateDNSProvider { zonesClient := newMockPrivateZonesClient(zones) recordSetsClient := newMockPrivateRecordSectsClient(recordSets) return newAzurePrivateDNSProvider(domainFilter, zoneNameFilter, zoneIDFilter, dryRun, resourceGroup, &zonesClient, &recordSetsClient, maxRetriesCount) } func newAzurePrivateDNSProvider(domainFilter *endpoint.DomainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, privateZonesClient PrivateZonesClient, privateRecordsClient PrivateRecordSetsClient, maxRetriesCount int) *AzurePrivateDNSProvider { return &AzurePrivateDNSProvider{ domainFilter: domainFilter, zoneNameFilter: zoneNameFilter, zoneIDFilter: zoneIDFilter, dryRun: dryRun, resourceGroup: resourceGroup, zonesClient: privateZonesClient, zonesCache: blueprint.NewZoneCache[[]privatedns.PrivateZone](0), recordSetsClient: privateRecordsClient, maxRetriesCount: maxRetriesCount, } } func TestAzurePrivateDNSRecord(t *testing.T) { provider := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"example.com"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{""}), true, "k8s", []*privatedns.PrivateZone{ createMockPrivateZone("example.com", "/privateDnsZones/example.com"), }, []*privatedns.RecordSet{ createPrivateMockRecordSet("NS", "ns1-03.azure-dns.com."), createPrivateMockRecordSet("SOA", "Email: azuredns-hostmaster.microsoft.com"), createPrivateMockRecordSet(endpoint.RecordTypeA, "123.123.123.122"), createPrivateMockRecordSet(endpoint.RecordTypeAAAA, "2001::123:123:123:122"), createPrivateMockRecordSet(endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createPrivateMockRecordSetWithNameAndTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), createPrivateMockRecordSetWithNameAndTTL("nginx", endpoint.RecordTypeAAAA, "2001::123:123:123:123", 3600), createPrivateMockRecordSetWithNameAndTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), createPrivateMockRecordSetWithNameAndTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), createPrivateMockRecordSetWithNameAndTTL("mail", endpoint.RecordTypeMX, "10 example.com", 4000), }, 3) actual, err := provider.Records(t.Context()) if err != nil { t.Fatal(err) } expected := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com"), } validateAzureEndpoints(t, actual, expected) } func TestAzurePrivateDNSMultiRecord(t *testing.T) { provider := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"example.com"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{""}), true, "k8s", []*privatedns.PrivateZone{ createMockPrivateZone("example.com", "/privateDnsZones/example.com"), }, []*privatedns.RecordSet{ createPrivateMockRecordSet("NS", "ns1-03.azure-dns.com."), createPrivateMockRecordSet("SOA", "Email: azuredns-hostmaster.microsoft.com"), createPrivateMockRecordSet(endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), createPrivateMockRecordSet(endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), createPrivateMockRecordSet(endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createPrivateMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), createPrivateMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), createPrivateMockRecordSetWithNameAndTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), createPrivateMockRecordSetWithNameAndTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), createPrivateMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), }, 3) actual, err := provider.Records(t.Context()) if err != nil { t.Fatal(err) } expected := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), } validateAzureEndpoints(t, actual, expected) } func TestAzurePrivateDNSApplyChanges(t *testing.T) { recordsClient := mockPrivateRecordSetsClient{} testAzurePrivateDNSApplyChangesInternal(t, false, &recordsClient) validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, ""), endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, ""), endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, ""), }) validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "other.com"), endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "5.6.7.8"), endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::5:6:7:8"), endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(recordTTL), "10 other.com"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), }) } func TestAzurePrivateDNSApplyChangesDryRun(t *testing.T) { recordsClient := mockPrivateRecordSetsClient{} testAzurePrivateDNSApplyChangesInternal(t, true, &recordsClient) validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{}) validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{}) } func testAzurePrivateDNSApplyChangesInternal(t *testing.T, dryRun bool, client PrivateRecordSetsClient) { zones := []*privatedns.PrivateZone{ createMockPrivateZone("example.com", "/privateDnsZones/example.com"), createMockPrivateZone("other.com", "/privateDnsZones/other.com"), } zonesClient := newMockPrivateZonesClient(zones) provider := newAzurePrivateDNSProvider( endpoint.NewDomainFilter([]string{""}), endpoint.NewDomainFilter([]string{""}), provider.NewZoneIDFilter([]string{""}), dryRun, "group", &zonesClient, client, 3, ) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:5", "2001::1:2:3:4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeA, "5.6.7.8"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeAAAA, "2001::5:6:7:8"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeA, "4.4.4.4"), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeAAAA, "2001::4:4:4:4"), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeMX, "10 other.com"), endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeTXT, "tag"), } currentRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("old.example.com", endpoint.RecordTypeA, "121.212.121.212"), endpoint.NewEndpoint("oldcname.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("old.nope.com", endpoint.RecordTypeA, "121.212.121.212"), endpoint.NewEndpoint("oldmail.example.com", endpoint.RecordTypeMX, "20 foo.other.com"), } updatedRecords := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeA, "222.111.222.111"), endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), } deleteRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, "111.222.111.222"), endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, "2001::111:222:111:222"), endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeA, "222.111.222.111"), endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), } changes := &plan.Changes{ Create: createRecords, UpdateNew: updatedRecords, UpdateOld: currentRecords, Delete: deleteRecords, } if err := provider.ApplyChanges(t.Context(), changes); err != nil { t.Fatal(err) } } func TestAzurePrivateDNSNameFilter(t *testing.T) { provider := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"nginx.example.com"}), endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true, "k8s", []*privatedns.PrivateZone{ createMockPrivateZone("example.com", "/privateDnsZones/example.com"), }, []*privatedns.RecordSet{ createPrivateMockRecordSet("NS", "ns1-03.azure-dns.com."), createPrivateMockRecordSet("SOA", "Email: azuredns-hostmaster.microsoft.com"), createPrivateMockRecordSet(endpoint.RecordTypeA, "123.123.123.122"), createPrivateMockRecordSet(endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createPrivateMockRecordSetWithNameAndTTL("test.nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), createPrivateMockRecordSetWithNameAndTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), createPrivateMockRecordSetWithNameAndTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), createPrivateMockRecordSetWithNameAndTTL("mail.nginx", endpoint.RecordTypeMX, "20 example.com", recordTTL), createPrivateMockRecordSetWithNameAndTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), }, 3) ctx := t.Context() actual, err := provider.Records(ctx) if err != nil { t.Fatal(err) } expected := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("test.nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("mail.nginx.example.com", endpoint.RecordTypeMX, recordTTL, "20 example.com"), } validateAzureEndpoints(t, actual, expected) } func TestAzurePrivateDNSApplyChangesZoneName(t *testing.T) { recordsClient := mockPrivateRecordSetsClient{} testAzurePrivateDNSApplyChangesInternalZoneName(t, false, &recordsClient) validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpoint("deleted.foo.example.com", endpoint.RecordTypeA, ""), endpoint.NewEndpoint("deletedaaaa.foo.example.com", endpoint.RecordTypeAAAA, ""), endpoint.NewEndpoint("deletedcname.foo.example.com", endpoint.RecordTypeCNAME, ""), }) validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.foo.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), }) } func testAzurePrivateDNSApplyChangesInternalZoneName(t *testing.T, dryRun bool, client PrivateRecordSetsClient) { zones := []*privatedns.PrivateZone{ createMockPrivateZone("example.com", "/privateDnsZones/example.com"), } zonesClient := newMockPrivateZonesClient(zones) provider := newAzurePrivateDNSProvider( endpoint.NewDomainFilter([]string{"foo.example.com"}), endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), dryRun, "group", &zonesClient, client, 3, ) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:5", "2001::1:2:3:4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeA, "5.6.7.8"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeA, "4.4.4.4"), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeTXT, "tag"), } currentRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("old.foo.example.com", endpoint.RecordTypeA, "121.212.121.212"), endpoint.NewEndpoint("oldcname.foo.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("old.nope.example.com", endpoint.RecordTypeA, "121.212.121.212"), } updatedRecords := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.foo.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), endpoint.NewEndpoint("new.nope.example.com", endpoint.RecordTypeA, "222.111.222.111"), endpoint.NewEndpoint("new.nope.example.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), } deleteRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("deleted.foo.example.com", endpoint.RecordTypeA, "111.222.111.222"), endpoint.NewEndpoint("deletedaaaa.foo.example.com", endpoint.RecordTypeAAAA, "2001::111:222:111:222"), endpoint.NewEndpoint("deletedcname.foo.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("deleted.nope.example.com", endpoint.RecordTypeA, "222.111.222.111"), } changes := &plan.Changes{ Create: createRecords, UpdateNew: updatedRecords, UpdateOld: currentRecords, Delete: deleteRecords, } if err := provider.ApplyChanges(t.Context(), changes); err != nil { t.Fatal(err) } } ================================================ FILE: provider/azure/azure_test.go ================================================ /* Copyright 2017 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 azure import ( "context" "testing" azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/provider/blueprint" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) // mockZonesClient implements the methods of the Azure DNS Zones Client which are used in the Azure Provider // and returns static results which are defined per test type mockZonesClient struct { pagingHandler azcoreruntime.PagingHandler[dns.ZonesClientListByResourceGroupResponse] } func newMockZonesClient(zones []*dns.Zone) mockZonesClient { pagingHandler := azcoreruntime.PagingHandler[dns.ZonesClientListByResourceGroupResponse]{ More: func(_ dns.ZonesClientListByResourceGroupResponse) bool { return false }, Fetcher: func(context.Context, *dns.ZonesClientListByResourceGroupResponse) (dns.ZonesClientListByResourceGroupResponse, error) { return dns.ZonesClientListByResourceGroupResponse{ ZoneListResult: dns.ZoneListResult{ Value: zones, }, }, nil }, } return mockZonesClient{ pagingHandler: pagingHandler, } } func (client *mockZonesClient) NewListByResourceGroupPager(_ string, _ *dns.ZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[dns.ZonesClientListByResourceGroupResponse] { return azcoreruntime.NewPager(client.pagingHandler) } // mockZonesClient implements the methods of the Azure DNS RecordSet Client which are used in the Azure Provider // and returns static results which are defined per test type mockRecordSetsClient struct { pagingHandler azcoreruntime.PagingHandler[dns.RecordSetsClientListAllByDNSZoneResponse] deletedEndpoints []*endpoint.Endpoint updatedEndpoints []*endpoint.Endpoint } func newMockRecordSetsClient(recordSets []*dns.RecordSet) mockRecordSetsClient { pagingHandler := azcoreruntime.PagingHandler[dns.RecordSetsClientListAllByDNSZoneResponse]{ More: func(_ dns.RecordSetsClientListAllByDNSZoneResponse) bool { return false }, Fetcher: func(context.Context, *dns.RecordSetsClientListAllByDNSZoneResponse) (dns.RecordSetsClientListAllByDNSZoneResponse, error) { return dns.RecordSetsClientListAllByDNSZoneResponse{ RecordSetListResult: dns.RecordSetListResult{ Value: recordSets, }, }, nil }, } return mockRecordSetsClient{ pagingHandler: pagingHandler, } } func (client *mockRecordSetsClient) NewListAllByDNSZonePager(_ string, _ string, _ *dns.RecordSetsClientListAllByDNSZoneOptions) *azcoreruntime.Pager[dns.RecordSetsClientListAllByDNSZoneResponse] { return azcoreruntime.NewPager(client.pagingHandler) } func (client *mockRecordSetsClient) Delete(_ context.Context, _ string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, _ *dns.RecordSetsClientDeleteOptions) (dns.RecordSetsClientDeleteResponse, error) { client.deletedEndpoints = append( client.deletedEndpoints, endpoint.NewEndpoint( formatAzureDNSName(relativeRecordSetName, zoneName), string(recordType), "", ), ) return dns.RecordSetsClientDeleteResponse{}, nil } func (client *mockRecordSetsClient) CreateOrUpdate(_ context.Context, _ string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, parameters dns.RecordSet, _ *dns.RecordSetsClientCreateOrUpdateOptions) (dns.RecordSetsClientCreateOrUpdateResponse, error) { var ttl endpoint.TTL if parameters.Properties.TTL != nil { ttl = endpoint.TTL(*parameters.Properties.TTL) } client.updatedEndpoints = append( client.updatedEndpoints, endpoint.NewEndpointWithTTL( formatAzureDNSName(relativeRecordSetName, zoneName), string(recordType), ttl, extractAzureTargets(¶meters)..., ), ) return dns.RecordSetsClientCreateOrUpdateResponse{}, nil } func createMockZone(zone string, id string) *dns.Zone { return &dns.Zone{ ID: to.Ptr(id), Name: to.Ptr(zone), } } func aRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { aRecords := make([]*dns.ARecord, len(values)) for i, value := range values { aRecords[i] = &dns.ARecord{ IPv4Address: to.Ptr(value), } } return &dns.RecordSetProperties{ TTL: to.Ptr(ttl), ARecords: aRecords, } } func aaaaRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { aaaaRecords := make([]*dns.AaaaRecord, len(values)) for i, value := range values { aaaaRecords[i] = &dns.AaaaRecord{ IPv6Address: to.Ptr(value), } } return &dns.RecordSetProperties{ TTL: to.Ptr(ttl), AaaaRecords: aaaaRecords, } } func cNameRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { return &dns.RecordSetProperties{ TTL: to.Ptr(ttl), CnameRecord: &dns.CnameRecord{ Cname: to.Ptr(values[0]), }, } } func mxRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { mxRecords := make([]*dns.MxRecord, len(values)) for i, target := range values { mxRecord, _ := parseMxTarget[dns.MxRecord](target) mxRecords[i] = &mxRecord } return &dns.RecordSetProperties{ TTL: to.Ptr(ttl), MxRecords: mxRecords, } } func nsRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { nsRecords := make([]*dns.NsRecord, len(values)) for i, value := range values { nsRecords[i] = &dns.NsRecord{ Nsdname: to.Ptr(value), } } return &dns.RecordSetProperties{ TTL: to.Ptr(ttl), NsRecords: nsRecords, } } func txtRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { return &dns.RecordSetProperties{ TTL: to.Ptr(ttl), TxtRecords: []*dns.TxtRecord{ { Value: []*string{to.Ptr(values[0])}, }, }, } } func othersRecordSetPropertiesGetter(_ []string, ttl int64) *dns.RecordSetProperties { return &dns.RecordSetProperties{ TTL: to.Ptr(ttl), } } func createMockRecordSet(name, recordType string, values ...string) *dns.RecordSet { return createMockRecordSetMultiWithTTL(name, recordType, 0, values...) } func createMockRecordSetWithTTL(name, recordType, value string, ttl int64) *dns.RecordSet { return createMockRecordSetMultiWithTTL(name, recordType, ttl, value) } func createMockRecordSetMultiWithTTL(name, recordType string, ttl int64, values ...string) *dns.RecordSet { var getterFunc func(values []string, ttl int64) *dns.RecordSetProperties switch recordType { case endpoint.RecordTypeA: getterFunc = aRecordSetPropertiesGetter case endpoint.RecordTypeAAAA: getterFunc = aaaaRecordSetPropertiesGetter case endpoint.RecordTypeCNAME: getterFunc = cNameRecordSetPropertiesGetter case endpoint.RecordTypeMX: getterFunc = mxRecordSetPropertiesGetter case endpoint.RecordTypeNS: getterFunc = nsRecordSetPropertiesGetter case endpoint.RecordTypeTXT: getterFunc = txtRecordSetPropertiesGetter default: getterFunc = othersRecordSetPropertiesGetter } return &dns.RecordSet{ Name: to.Ptr(name), Type: to.Ptr("Microsoft.Network/dnszones/" + recordType), Properties: getterFunc(values, ttl), } } // newMockedAzureProvider creates an AzureProvider comprising the mocked clients for zones and recordsets func newMockedAzureProvider(domainFilter *endpoint.DomainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, userAssignedIdentityClientID string, activeDirectoryAuthorityHost string, zones []*dns.Zone, recordSets []*dns.RecordSet, maxRetriesCount int) *AzureProvider { zonesClient := newMockZonesClient(zones) recordSetsClient := newMockRecordSetsClient(recordSets) return newAzureProvider(domainFilter, zoneNameFilter, zoneIDFilter, dryRun, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost, &zonesClient, &recordSetsClient, maxRetriesCount) } func newAzureProvider(domainFilter *endpoint.DomainFilter, zoneNameFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, userAssignedIdentityClientID string, activeDirectoryAuthorityHost string, zonesClient ZonesClient, recordsClient RecordSetsClient, maxRetriesCount int) *AzureProvider { return &AzureProvider{ domainFilter: domainFilter, zoneNameFilter: zoneNameFilter, zoneIDFilter: zoneIDFilter, dryRun: dryRun, resourceGroup: resourceGroup, userAssignedIdentityClientID: userAssignedIdentityClientID, activeDirectoryAuthorityHost: activeDirectoryAuthorityHost, zonesClient: zonesClient, zonesCache: blueprint.NewZoneCache[[]dns.Zone](0), recordSetsClient: recordsClient, maxRetriesCount: maxRetriesCount, } } func validateAzureEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) { assert.True(t, testutils.SameEndpoints(endpoints, expected), "actual and expected endpoints don't match. %s:%s", endpoints, expected) } func TestAzureRecord(t *testing.T) { provider := newMockedAzureProvider(endpoint.NewDomainFilter([]string{"example.com"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{""}), true, "k8s", "", "", []*dns.Zone{ createMockZone("example.com", "/dnszones/example.com"), }, []*dns.RecordSet{ createMockRecordSet("@", endpoint.RecordTypeNS, "ns1-03.azure-dns.com."), createMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), createMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122"), createMockRecordSet("@", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), createMockRecordSet("cloud", endpoint.RecordTypeNS, "ns1.example.com."), createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeAAAA, "2001::123:123:123:123", 3600), createMockRecordSetWithTTL("cloud-ttl", endpoint.RecordTypeNS, "ns1-ttl.example.com.", 10), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), createMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com"), }, 3) ctx := t.Context() actual, err := provider.Records(ctx) if err != nil { t.Fatal(err) } expected := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeNS, "ns1-03.azure-dns.com."), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), endpoint.NewEndpoint("cloud.example.com", endpoint.RecordTypeNS, "ns1.example.com."), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123"), endpoint.NewEndpointWithTTL("cloud-ttl.example.com", endpoint.RecordTypeNS, 10, "ns1-ttl.example.com."), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com"), } validateAzureEndpoints(t, actual, expected) } func TestAzureMultiRecord(t *testing.T) { provider := newMockedAzureProvider(endpoint.NewDomainFilter([]string{"example.com"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{""}), true, "k8s", "", "", []*dns.Zone{ createMockZone("example.com", "/dnszones/example.com"), }, []*dns.RecordSet{ createMockRecordSet("@", endpoint.RecordTypeNS, "ns1-03.azure-dns.com."), createMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), createMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), createMockRecordSet("@", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), createMockRecordSet("cloud", endpoint.RecordTypeNS, "ns1.example.com.", "ns2.example.com."), createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), createMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), createMockRecordSetMultiWithTTL("cloud-ttl", endpoint.RecordTypeNS, 10, "ns1-ttl.example.com.", "ns2-ttl.example.com."), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), createMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), }, 3) ctx := t.Context() actual, err := provider.Records(ctx) if err != nil { t.Fatal(err) } expected := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeNS, "ns1-03.azure-dns.com."), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), endpoint.NewEndpoint("cloud.example.com", endpoint.RecordTypeNS, "ns1.example.com.", "ns2.example.com."), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), endpoint.NewEndpointWithTTL("cloud-ttl.example.com", endpoint.RecordTypeNS, 10, "ns1-ttl.example.com.", "ns2-ttl.example.com."), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), } validateAzureEndpoints(t, actual, expected) } func TestAzureApplyChanges(t *testing.T) { recordsClient := mockRecordSetsClient{} testAzureApplyChangesInternal(t, false, &recordsClient) validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, ""), endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, ""), endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, ""), endpoint.NewEndpoint("deletedns.example.com", endpoint.RecordTypeNS, ""), }) validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), endpoint.NewEndpointWithTTL("cloud.example.com", endpoint.RecordTypeNS, endpoint.TTL(recordTTL), "ns1.example.com."), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "other.com"), endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "5.6.7.8"), endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::5:6:7:8"), endpoint.NewEndpointWithTTL("cloud.other.com", endpoint.RecordTypeNS, endpoint.TTL(recordTTL), "ns2.other.com."), endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), endpoint.NewEndpointWithTTL("newns.example.com", endpoint.RecordTypeNS, 10, "ns1.example.com."), endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(recordTTL), "10 other.com"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), }) } func TestAzureApplyChangesDryRun(t *testing.T) { recordsClient := mockRecordSetsClient{} testAzureApplyChangesInternal(t, true, &recordsClient) validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{}) validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{}) } func testAzureApplyChangesInternal(t *testing.T, dryRun bool, client RecordSetsClient) { zones := []*dns.Zone{ createMockZone("example.com", "/dnszones/example.com"), createMockZone("other.com", "/dnszones/other.com"), } zonesClient := newMockZonesClient(zones) provider := newAzureProvider( endpoint.NewDomainFilter([]string{""}), endpoint.NewDomainFilter([]string{""}), provider.NewZoneIDFilter([]string{""}), dryRun, "group", "", "", &zonesClient, client, 3, ) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:5", "2001::1:2:3:4"), endpoint.NewEndpoint("cloud.example.com", endpoint.RecordTypeNS, "ns1.example.com."), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeA, "5.6.7.8"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeAAAA, "2001::5:6:7:8"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("cloud.other.com", endpoint.RecordTypeNS, "ns2.other.com."), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeA, "4.4.4.4"), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeAAAA, "2001::4:4:4:4"), endpoint.NewEndpoint("cloud.nope.com", endpoint.RecordTypeNS, "ns1.nope.com."), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeMX, "10 other.com"), endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeTXT, "tag"), } currentRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("old.example.com", endpoint.RecordTypeA, "121.212.121.212"), endpoint.NewEndpoint("oldcname.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("oldcloud.example.com", endpoint.RecordTypeNS, "ns1.example.com."), endpoint.NewEndpoint("old.nope.com", endpoint.RecordTypeA, "121.212.121.212"), endpoint.NewEndpoint("oldmail.example.com", endpoint.RecordTypeMX, "20 foo.other.com"), } updatedRecords := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), endpoint.NewEndpointWithTTL("newns.example.com", endpoint.RecordTypeNS, 10, "ns1.example.com."), endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeA, "222.111.222.111"), endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), endpoint.NewEndpoint("newns.nope.com", endpoint.RecordTypeNS, "ns1.example.com"), endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), } deleteRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, "111.222.111.222"), endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, "2001::111:222:111:222"), endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("deletedns.example.com", endpoint.RecordTypeNS, "ns1.example.com."), endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeA, "222.111.222.111"), endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), endpoint.NewEndpoint("deletedns.nope.com", endpoint.RecordTypeNS, "ns1.example.com."), } changes := &plan.Changes{ Create: createRecords, UpdateNew: updatedRecords, UpdateOld: currentRecords, Delete: deleteRecords, } if err := provider.ApplyChanges(t.Context(), changes); err != nil { t.Fatal(err) } } func TestAzureNameFilter(t *testing.T) { provider := newMockedAzureProvider(endpoint.NewDomainFilter([]string{"nginx.example.com"}), endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true, "k8s", "", "", []*dns.Zone{ createMockZone("example.com", "/dnszones/example.com"), }, []*dns.RecordSet{ createMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), createMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), createMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122"), createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createMockRecordSetWithTTL("test.nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeNS, "ns1.example.com.", 3600), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), createMockRecordSetWithTTL("mail.nginx", endpoint.RecordTypeMX, "20 example.com", recordTTL), createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), createMockRecordSetWithTTL("hack", endpoint.RecordTypeNS, "ns1.example.com.", 3600), }, 3) ctx := t.Context() actual, err := provider.Records(ctx) if err != nil { t.Fatal(err) } expected := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("test.nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeNS, 3600, "ns1.example.com."), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("mail.nginx.example.com", endpoint.RecordTypeMX, recordTTL, "20 example.com"), } validateAzureEndpoints(t, actual, expected) } func TestAzureApplyChangesZoneName(t *testing.T) { recordsClient := mockRecordSetsClient{} testAzureApplyChangesInternalZoneName(t, false, &recordsClient) validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpoint("deleted.foo.example.com", endpoint.RecordTypeA, ""), endpoint.NewEndpoint("deletedaaaa.foo.example.com", endpoint.RecordTypeAAAA, ""), endpoint.NewEndpoint("deletedcname.foo.example.com", endpoint.RecordTypeCNAME, ""), endpoint.NewEndpoint("deletedns.foo.example.com", endpoint.RecordTypeNS, ""), }) validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeNS, endpoint.TTL(recordTTL), "ns1.example.com."), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newns.foo.example.com", endpoint.RecordTypeNS, 10, "ns1.foo.example.com."), endpoint.NewEndpointWithTTL("newcname.foo.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), }) } func testAzureApplyChangesInternalZoneName(t *testing.T, dryRun bool, client RecordSetsClient) { zonesClient := newMockZonesClient([]*dns.Zone{createMockZone("example.com", "/dnszones/example.com")}) provider := newAzureProvider( endpoint.NewDomainFilter([]string{"foo.example.com"}), endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), dryRun, "group", "", "", &zonesClient, client, 3, ) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:5", "2001::1:2:3:4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeNS, "ns1.example.com."), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("barns.example.com", endpoint.RecordTypeNS, "ns1.example.com."), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeA, "5.6.7.8"), endpoint.NewEndpoint("foons.other.com", endpoint.RecordTypeNS, "ns1.other.com"), endpoint.NewEndpoint("other.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeA, "4.4.4.4"), endpoint.NewEndpoint("nope.com", endpoint.RecordTypeTXT, "tag"), } currentRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("old.foo.example.com", endpoint.RecordTypeA, "121.212.121.212"), endpoint.NewEndpoint("oldcname.foo.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("old.nope.example.com", endpoint.RecordTypeA, "121.212.121.212"), } updatedRecords := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.foo.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), endpoint.NewEndpointWithTTL("newns.foo.example.com", endpoint.RecordTypeNS, 10, "ns1.foo.example.com."), endpoint.NewEndpoint("new.nope.example.com", endpoint.RecordTypeA, "222.111.222.111"), endpoint.NewEndpoint("new.nope.example.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), endpoint.NewEndpointWithTTL("newns.nope.example.com", endpoint.RecordTypeNS, 10, "ns1.nope.example.com."), } deleteRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("deleted.foo.example.com", endpoint.RecordTypeA, "111.222.111.222"), endpoint.NewEndpoint("deletedaaaa.foo.example.com", endpoint.RecordTypeAAAA, "2001::111:222:111:222"), endpoint.NewEndpoint("deletedcname.foo.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("deletedns.foo.example.com", endpoint.RecordTypeNS, "ns1.foo.example.com."), endpoint.NewEndpoint("deleted.nope.example.com", endpoint.RecordTypeA, "222.111.222.111"), } changes := &plan.Changes{ Create: createRecords, UpdateNew: updatedRecords, UpdateOld: currentRecords, Delete: deleteRecords, } if err := provider.ApplyChanges(t.Context(), changes); err != nil { t.Fatal(err) } } ================================================ FILE: provider/azure/common.go ================================================ /* Copyright 2017 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. */ //nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go package azure import ( "fmt" "strconv" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" ) // Helper function (shared with test code) func parseMxTarget[T dns.MxRecord | privatedns.MxRecord](mxTarget string) (T, error) { targetParts := strings.SplitN(mxTarget, " ", 2) if len(targetParts) != 2 { return T{}, fmt.Errorf("mx target needs to be of form '10 example.com'") } preferenceRaw, exchange := targetParts[0], targetParts[1] preference, err := strconv.ParseInt(preferenceRaw, 10, 32) if err != nil { return T{}, fmt.Errorf("invalid preference specified") } return T{ Preference: to.Ptr(int32(preference)), Exchange: to.Ptr(exchange), }, nil } ================================================ FILE: provider/azure/common_test.go ================================================ /* Copyright 2017 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 azure import ( "fmt" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" "github.com/stretchr/testify/assert" ) func Test_parseMxTarget(t *testing.T) { type testCase[T interface { dns.MxRecord | privatedns.MxRecord }] struct { name string args string want T wantErr assert.ErrorAssertionFunc } tests := []testCase[dns.MxRecord]{ { name: "valid mx target", args: "10 example.com", want: dns.MxRecord{ Preference: to.Ptr(int32(10)), Exchange: to.Ptr("example.com"), }, wantErr: assert.NoError, }, { name: "valid mx target with a subdomain", args: "99 foo-bar.example.com", want: dns.MxRecord{ Preference: to.Ptr(int32(99)), Exchange: to.Ptr("foo-bar.example.com"), }, wantErr: assert.NoError, }, { name: "invalid mx target with misplaced preference and exchange", args: "example.com 10", want: dns.MxRecord{}, wantErr: assert.Error, }, { name: "invalid mx target without preference", args: "example.com", want: dns.MxRecord{}, wantErr: assert.Error, }, { name: "invalid mx target with non numeric preference", args: "aa example.com", want: dns.MxRecord{}, wantErr: assert.Error, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseMxTarget[dns.MxRecord](tt.args) if !tt.wantErr(t, err, fmt.Sprintf("parseMxTarget(%v)", tt.args)) { return } assert.Equalf(t, tt.want, got, "parseMxTarget(%v)", tt.args) }) } } ================================================ FILE: provider/azure/config.go ================================================ /* Copyright 2017 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 azure import ( "context" "encoding/json" "fmt" "net/http" "os" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/google/uuid" log "github.com/sirupsen/logrus" ) // config represents common config items for Azure DNS and Azure Private DNS type config struct { Cloud string `json:"cloud" yaml:"cloud"` TenantID string `json:"tenantId" yaml:"tenantId"` SubscriptionID string `json:"subscriptionId" yaml:"subscriptionId"` ResourceGroup string `json:"resourceGroup" yaml:"resourceGroup"` Location string `json:"location" yaml:"location"` ClientID string `json:"aadClientId" yaml:"aadClientId"` ClientSecret string `json:"aadClientSecret" yaml:"aadClientSecret"` UseManagedIdentityExtension bool `json:"useManagedIdentityExtension" yaml:"useManagedIdentityExtension"` UseWorkloadIdentityExtension bool `json:"useWorkloadIdentityExtension" yaml:"useWorkloadIdentityExtension"` UserAssignedIdentityID string `json:"userAssignedIdentityID" yaml:"userAssignedIdentityID"` ActiveDirectoryAuthorityHost string `json:"activeDirectoryAuthorityHost" yaml:"activeDirectoryAuthorityHost"` ResourceManagerAudience string `json:"resourceManagerAudience" yaml:"resourceManagerAudience"` ResourceManagerEndpoint string `json:"resourceManagerEndpoint" yaml:"resourceManagerEndpoint"` } func getConfig(configFile, subscriptionID, resourceGroup, userAssignedIdentityClientID, activeDirectoryAuthorityHost string) (*config, error) { contents, err := os.ReadFile(configFile) if err != nil { return nil, fmt.Errorf("failed to read Azure config file '%s': %w", configFile, err) } cfg := &config{} if err := json.Unmarshal(contents, &cfg); err != nil { return nil, fmt.Errorf("failed to parse Azure config file '%s': %w", configFile, err) } // If a subscription ID was given, override what was present in the config file if subscriptionID != "" { cfg.SubscriptionID = subscriptionID } // If a resource group was given, override what was present in the config file if resourceGroup != "" { cfg.ResourceGroup = resourceGroup } // If userAssignedIdentityClientID is provided explicitly, override existing one in config file if userAssignedIdentityClientID != "" { cfg.UserAssignedIdentityID = userAssignedIdentityClientID } // If activeDirectoryAuthorityHost is provided explicitly, override existing one in config file if activeDirectoryAuthorityHost != "" { cfg.ActiveDirectoryAuthorityHost = activeDirectoryAuthorityHost } return cfg, nil } // ctxKey is a type for context keys // This is used to avoid collisions with other packages that may use the same key in the context. type ctxKey string const ( // Context key for request ID clientRequestIDKey ctxKey = "client-request-id" // Azure API Headers msRequestIDHeader = "x-ms-request-id" msCorrelationRequestHeader = "x-ms-correlation-request-id" msClientRequestIDHeader = "x-ms-client-request-id" ) // customHeaderPolicy adds UUID to request headers type customHeaderPolicy struct{} func (p *customHeaderPolicy) Do(req *policy.Request) (*http.Response, error) { id := req.Raw().Header.Get(msClientRequestIDHeader) if id == "" { id = uuid.New().String() req.Raw().Header.Set(msClientRequestIDHeader, id) newCtx := context.WithValue(req.Raw().Context(), clientRequestIDKey, id) *req.Raw() = *req.Raw().WithContext(newCtx) } return req.Next() } func CustomHeaderPolicynew() policy.Policy { return &customHeaderPolicy{} } // getCredentials retrieves Azure API credentials. func getCredentials(cfg config, maxRetries int) (azcore.TokenCredential, *arm.ClientOptions, error) { cloudCfg, err := getCloudConfiguration(cfg) if err != nil { return nil, nil, fmt.Errorf("failed to get cloud configuration: %w", err) } clientOpts := azcore.ClientOptions{ Cloud: cloudCfg, Retry: policy.RetryOptions{ MaxRetries: int32(maxRetries), }, Logging: policy.LogOptions{ AllowedHeaders: []string{ msRequestIDHeader, msCorrelationRequestHeader, msClientRequestIDHeader, }, }, PerCallPolicies: []policy.Policy{ CustomHeaderPolicynew(), }, } log.Debugf("Configured Azure client with maxRetries: %d", clientOpts.Retry.MaxRetries) armClientOpts := &arm.ClientOptions{ ClientOptions: clientOpts, } // Try to retrieve token with service principal credentials. // Try to use service principal first, some AKS clusters are in an intermediate state that `UseManagedIdentityExtension` is `true` // and service principal exists. In this case, we still want to use service principal to authenticate. if len(cfg.ClientID) > 0 && len(cfg.ClientSecret) > 0 && // due to some historical reason, for pure MSI cluster, // they will use "msi" as placeholder in azure.json. // In this case, we shouldn't try to use SPN to authenticate. !strings.EqualFold(cfg.ClientID, "msi") && !strings.EqualFold(cfg.ClientSecret, "msi") { log.Info("Using client_id+client_secret to retrieve access token for Azure API.") opts := &azidentity.ClientSecretCredentialOptions{ ClientOptions: clientOpts, } cred, err := azidentity.NewClientSecretCredential(cfg.TenantID, cfg.ClientID, cfg.ClientSecret, opts) if err != nil { return nil, nil, fmt.Errorf("failed to create service principal token: %w", err) } return cred, armClientOpts, nil } // Try to retrieve token with Workload Identity. if cfg.UseWorkloadIdentityExtension { log.Info("Using workload identity extension to retrieve access token for Azure API.") wiOpt := azidentity.WorkloadIdentityCredentialOptions{ ClientOptions: clientOpts, // In a standard scenario, Client ID and Tenant ID are expected to be read from environment variables. // Though, in certain cases, it might be important to have an option to override those (e.g. when AZURE_TENANT_ID is not set // through a webhook or azure.workload.identity/client-id service account annotation is absent). When any of those values are // empty in our config, they will automatically be read from environment variables by azidentity TenantID: cfg.TenantID, ClientID: cfg.ClientID, } cred, err := azidentity.NewWorkloadIdentityCredential(&wiOpt) if err != nil { return nil, nil, fmt.Errorf("failed to create a workload identity token: %w", err) } return cred, armClientOpts, nil } // Try to retrieve token with MSI. if cfg.UseManagedIdentityExtension { log.Info("Using managed identity extension to retrieve access token for Azure API.") msiOpt := azidentity.ManagedIdentityCredentialOptions{ ClientOptions: clientOpts, } if cfg.UserAssignedIdentityID != "" { msiOpt.ID = azidentity.ClientID(cfg.UserAssignedIdentityID) } cred, err := azidentity.NewManagedIdentityCredential(&msiOpt) if err != nil { return nil, nil, fmt.Errorf("failed to create the managed service identity token: %w", err) } return cred, armClientOpts, nil } return nil, nil, fmt.Errorf("no credentials provided for Azure API") } func getCloudConfiguration(cfg config) (cloud.Configuration, error) { name := strings.ToUpper(cfg.Cloud) switch name { case "AZURECLOUD", "AZUREPUBLICCLOUD", "": return cloud.AzurePublic, nil case "AZUREUSGOVERNMENT", "AZUREUSGOVERNMENTCLOUD": return cloud.AzureGovernment, nil case "AZURECHINACLOUD": return cloud.AzureChina, nil case "AZURESTACKCLOUD": return cloud.Configuration{ ActiveDirectoryAuthorityHost: cfg.ActiveDirectoryAuthorityHost, Services: map[cloud.ServiceName]cloud.ServiceConfiguration{ cloud.ResourceManager: { Audience: cfg.ResourceManagerAudience, Endpoint: cfg.ResourceManagerEndpoint, }, }, }, nil } return cloud.Configuration{}, fmt.Errorf("unknown cloud name: %s", name) } ================================================ FILE: provider/azure/config_test.go ================================================ /* Copyright 2017 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 azure import ( "fmt" "io" "net/http" "path" "runtime" "strconv" "strings" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" azruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/stretchr/testify/assert" ) func TestGetCloudConfiguration(t *testing.T) { tests := map[string]struct { cloudName string expected cloud.Configuration }{ "AzureChinaCloud": {"AzureChinaCloud", cloud.AzureChina}, "AzurePublicCloud": {"", cloud.AzurePublic}, "AzureUSGovernment": {"AzureUSGovernmentCloud", cloud.AzureGovernment}, } for name, test := range tests { t.Run(name, func(t *testing.T) { cfg := config{Cloud: test.cloudName} cloudCfg, err := getCloudConfiguration(cfg) if err != nil { t.Errorf("got unexpected err %v", err) } if cloudCfg.ActiveDirectoryAuthorityHost != test.expected.ActiveDirectoryAuthorityHost { t.Errorf("got %v, want %v", cloudCfg, test.expected) } }) } } func TestOverrideConfiguration(t *testing.T) { _, filename, _, _ := runtime.Caller(0) configFile := path.Join(path.Dir(filename), "fixtures/config_test.json") cfg, err := getConfig(configFile, "subscription-override", "rg-override", "", "aad-endpoint-override") if err != nil { t.Errorf("got unexpected err %v", err) } assert.Equal(t, "subscription-override", cfg.SubscriptionID) assert.Equal(t, "rg-override", cfg.ResourceGroup) assert.Equal(t, "aad-endpoint-override", cfg.ActiveDirectoryAuthorityHost) } // Test for custom header policy type transportFunc func(*http.Request) (*http.Response, error) func (f transportFunc) Do(req *http.Request) (*http.Response, error) { return f(req) } func TestCustomHeaderPolicyWithRetries(t *testing.T) { // Set up test environment flagValue := "-6" retries, err := parseMaxRetries(flagValue) if err != nil { t.Fatalf("Failed to parse retries: %v", err) } maxRetries := int32(retries) // Flag was provided with non-zero value maxRetries = int32(retries) t.Logf("Using provided flag value: %d", retries) var attempt int32 var firstRequestID string // Create mock transport that simulates 429 responses mockTransport := transportFunc(func(req *http.Request) (*http.Response, error) { attempt++ // Get the request ID from header requestID := req.Header.Get("x-ms-client-request-id") if requestID == "" { t.Fatalf("Request ID missing on attempt %d", attempt) } // On first attempt, store the request ID if attempt == 1 { firstRequestID = requestID t.Logf("Initial request ID: %s", firstRequestID) } else { // On subsequent attempts, verify it matches the first request ID if requestID != firstRequestID { t.Fatalf("Request ID changed on retry %d: got %s, want %s", attempt, requestID, firstRequestID) } else { t.Logf("Request ID preserved on attempt %d: %s", attempt, requestID) } } // Verify the ID is also in the context if ctxID, ok := req.Context().Value(clientRequestIDKey).(string); !ok || ctxID != requestID { t.Errorf("Context ID mismatch on attempt %d: got %v, want %s", attempt, ctxID, requestID) } // Return 429 for all but the last attempt if maxRetries < 0 || attempt <= maxRetries { t.Logf("Attempt %d: THROTTLED (429) - Request ID: %s", attempt, requestID) return &http.Response{ StatusCode: http.StatusTooManyRequests, Body: io.NopCloser(strings.NewReader("Too many requests")), Request: req, Header: http.Header{ "x-ms-client-request-id": []string{requestID}, "Retry-After": []string{"1"}, }, }, nil } // Return 200 on final attempt t.Logf("Attempt %d: SUCCESS (200) - Request ID: %s", attempt, requestID) return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("Success")), Request: req, Header: http.Header{ "x-ms-client-request-id": []string{requestID}, }, }, nil }) // Create pipeline with retry policy and custom header policy mockPipeline := azruntime.NewPipeline( "testmodule", "1.0", azruntime.PipelineOptions{ PerCall: []policy.Policy{ CustomHeaderPolicynew(), }, }, &policy.ClientOptions{ Retry: policy.RetryOptions{ MaxRetries: maxRetries, }, Transport: mockTransport, }, ) // Create request and execute req, err := azruntime.NewRequest(t.Context(), http.MethodGet, "https://example.com") if err != nil { t.Fatalf("Failed to create request: %v", err) } resp, err := mockPipeline.Do(req) if err != nil { t.Fatalf("Request failed: %v", err) } defer resp.Body.Close() // Verify we got the expected number of attempts var expectedAttempts int32 if maxRetries < 0 { expectedAttempts = 1 // For negative retries, only one attempt should be made } else { expectedAttempts = maxRetries + 1 // For zero or positive retries, attempts = retries + 1 } if attempt != expectedAttempts { t.Errorf("Wrong number of attempts: got %d, want %d", attempt, expectedAttempts) } t.Logf("Test completed with %d attempts, all with request ID: %s", attempt, firstRequestID) } func TestMaxRetriesCount(t *testing.T) { defaultRetries := 3 tests := []struct { name string input string isSet bool // indicates if flag was provided expected int shouldError bool description string }{ { name: "FlagNotProvided", input: "", isSet: false, expected: defaultRetries, shouldError: false, description: "When flag is not provided, should use default value", }, { name: "FlagProvidedEmpty", input: "", isSet: true, expected: 0, shouldError: true, description: "When flag is provided but empty, should error", }, { name: "ValidPositive", input: "5", isSet: true, expected: 5, shouldError: false, description: "Valid positive number should be accepted", }, { name: "ZeroRetries", input: "0", isSet: true, expected: 0, shouldError: false, description: "Zero should be accepted and handled by SDK", }, { name: "NegativeRetries", input: "-2", isSet: true, expected: -2, shouldError: false, description: "Negative values should be accepted and handled by SDK", }, { name: "InvalidString", input: "abc", isSet: true, expected: 0, shouldError: true, description: "Non-numeric string should error", }, { name: "Whitespace", input: " ", isSet: true, expected: 0, shouldError: true, description: "Whitespace should error", }, { name: "SpecialChars", input: "@#$%", isSet: true, expected: 0, shouldError: true, description: "Special characters should error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Logf("=== Test Case: %s ===", tt.name) t.Logf("Description: %s", tt.description) t.Logf("Input: %q (flag provided: %v)", tt.input, tt.isSet) // Handle flag not provided case if !tt.isSet { t.Logf("Using default value: %d", defaultRetries) return } retries, err := parseMaxRetries(tt.input) // Check error condition if tt.shouldError { if err == nil { t.Errorf("Expected error for input %q but got none", tt.input) } else { t.Logf("Got expected error: %v", err) } } else { if err != nil { t.Errorf("Unexpected error: %v", err) } if retries != tt.expected { t.Errorf("Got %d retries, want %d", retries, tt.expected) } else { t.Logf("Got expected value: %d", retries) } } }) } } // Helper function to parse max retries value func parseMaxRetries(value string) (int, error) { // Trim whitespace value = strings.TrimSpace(value) // Empty string or whitespace should error if value == "" { return 0, fmt.Errorf("retry count must be provided when flag is set") } retries, err := strconv.Atoi(value) if err != nil { return 0, fmt.Errorf("invalid retry count %q: %w", value, err) } return retries, nil } ================================================ FILE: provider/azure/fixtures/config_test.json ================================================ { "tenantId": "tenant", "subscriptionId": "subscription", "resourceGroup": "rg", "aadClientId": "clientId", "aadClientSecret": "clientSecret" } ================================================ FILE: provider/blueprint/zone_cache.go ================================================ /* Copyright 2026 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 blueprint import ( "sync" "time" log "github.com/sirupsen/logrus" ) // ZoneCache is a generic cache for DNS zones with TTL-based expiration. // It can store any type of zone data and provides thread-safe access. type ZoneCache[T any] struct { mu sync.RWMutex age time.Time duration time.Duration data T } // NewZoneCache creates a new ZoneCache with the specified TTL duration. // A duration of 0 or less disables caching: Reset becomes a no-op and Expired always returns true. func NewZoneCache[T any](duration time.Duration) *ZoneCache[T] { return &ZoneCache[T]{duration: duration} } // Get returns the cached data. Returns the zero value if the cache has never been populated. // Data is not cleared on expiration; Get returns the last known value until Reset is called again. func (c *ZoneCache[T]) Get() T { c.mu.RLock() defer c.mu.RUnlock() return c.data } // Reset updates the cached data and refreshes the age timestamp. // Only updates if caching is enabled (duration > 0). func (c *ZoneCache[T]) Reset(data T) { if c.duration <= 0 { return } c.mu.Lock() defer c.mu.Unlock() c.data = data c.age = time.Now() log.WithField("duration", c.duration).Debug("zone cache reset") } // Expired returns true if the cache is empty (never populated) or the TTL has elapsed since // the last Reset. When caching is disabled (duration <= 0), always returns true. func (c *ZoneCache[T]) Expired() bool { c.mu.RLock() defer c.mu.RUnlock() return c.age.IsZero() || time.Since(c.age) > c.duration } ================================================ FILE: provider/blueprint/zone_cache_test.go ================================================ /* Copyright 2026 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 blueprint import ( "sync" "sync/atomic" "testing" "testing/synctest" "time" "github.com/stretchr/testify/assert" ) func TestZoneCache_SliceCache(t *testing.T) { cache := NewZoneCache[[]string](time.Hour) // Initially expired (empty) assert.True(t, cache.Expired()) // After reset, not expired cache.Reset([]string{"zone1", "zone2"}) assert.False(t, cache.Expired()) assert.Equal(t, []string{"zone1", "zone2"}, cache.Get()) } func TestZoneCache_MapCache(t *testing.T) { cache := NewZoneCache[map[string]int](time.Hour) // Initially expired (empty) assert.True(t, cache.Expired()) // After reset, not expired cache.Reset(map[string]int{"a": 1, "b": 2}) assert.False(t, cache.Expired()) assert.Equal(t, map[string]int{"a": 1, "b": 2}, cache.Get()) } func TestZoneCache_Expiration(t *testing.T) { // Very short duration for testing cache := NewZoneCache[[]string](10 * time.Millisecond) cache.Reset([]string{"zone1"}) assert.False(t, cache.Expired()) // Wait for expiration time.Sleep(20 * time.Millisecond) assert.True(t, cache.Expired()) } func TestZoneCache_CachingDisabled(t *testing.T) { cache := NewZoneCache[[]string](0) cache.Reset([]string{"zone1"}) // Should still be expired because caching is disabled assert.True(t, cache.Expired()) // Data should not be stored assert.Nil(t, cache.Get()) } func TestZoneCache_Expiration_Synctest(t *testing.T) { synctest.Test(t, func(t *testing.T) { cache := NewZoneCache[[]string](5 * time.Minute) cache.Reset([]string{"zone1", "zone2"}) assert.False(t, cache.Expired(), "should not be expired immediately after reset") assert.Equal(t, []string{"zone1", "zone2"}, cache.Get()) // Advance time but not past expiration time.Sleep(3 * time.Minute) assert.False(t, cache.Expired(), "should not be expired before duration") // Advance time past expiration time.Sleep(3 * time.Minute) // Total: 6 minutes > 5 minute duration assert.True(t, cache.Expired(), "should be expired after duration") // Data is still accessible even when expired assert.Equal(t, []string{"zone1", "zone2"}, cache.Get()) // Reset refreshes the cache cache.Reset([]string{"zone3"}) assert.False(t, cache.Expired(), "should not be expired after fresh reset") assert.Equal(t, []string{"zone3"}, cache.Get()) }) } func TestZoneCache_ThreadSafety(t *testing.T) { cache := NewZoneCache[[]int](time.Hour) var wg sync.WaitGroup const numWriters = 3 const numReaders = 5 const iterations = 100 var validReads atomic.Int64 // Writer goroutines for w := range numWriters { wg.Add(1) go func(writerID int) { defer wg.Done() for i := range iterations { cache.Reset([]int{writerID, i}) } }(w) } // Reader goroutines for range numReaders { wg.Go(func() { for range iterations { data := cache.Get() expired := cache.Expired() // Verify data consistency: if we got data, it should be valid if data != nil { assert.Len(t, data, 2, "cached slice should always have exactly 2 elements") assert.GreaterOrEqual(t, data[0], 0) assert.Less(t, data[0], numWriters) assert.GreaterOrEqual(t, data[1], 0) assert.Less(t, data[1], iterations) validReads.Add(1) } // Expired is a valid boolean - just verify it doesn't panic _ = expired } }) } wg.Wait() // After all writes complete, cache should have valid final state finalData := cache.Get() assert.NotNil(t, finalData, "cache should have data after writes") assert.Len(t, finalData, 2, "final data should have 2 elements") assert.False(t, cache.Expired(), "cache should not be expired") } ================================================ FILE: provider/cached_provider.go ================================================ /* Copyright 2017 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 provider import ( "context" "time" "github.com/prometheus/client_golang/prometheus" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/metrics" "sigs.k8s.io/external-dns/plan" ) var ( cachedRecordsCallsTotal = metrics.NewCounterVecWithOpts( prometheus.CounterOpts{ Subsystem: "provider", Name: "cache_records_calls", Help: "Number of calls to the provider cache Records list.", }, []string{ "from_cache", }, ) cachedApplyChangesCallsTotal = metrics.NewCounterWithOpts( prometheus.CounterOpts{ Subsystem: "provider", Name: "cache_apply_changes_calls", Help: "Number of calls to the provider cache ApplyChanges.", }, ) ) func init() { metrics.RegisterMetric.MustRegister(cachedRecordsCallsTotal) metrics.RegisterMetric.MustRegister(cachedApplyChangesCallsTotal) } type CachedProvider struct { Provider RefreshDelay time.Duration lastRead time.Time cache []*endpoint.Endpoint } func NewCachedProvider(provider Provider, refreshDelay time.Duration) *CachedProvider { return &CachedProvider{ Provider: provider, RefreshDelay: refreshDelay, } } func (c *CachedProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { if c.needRefresh() { log.Info("Records cache provider: refreshing records list cache") records, err := c.Provider.Records(ctx) if err != nil { c.cache = nil return nil, err } c.cache = records c.lastRead = time.Now() cachedRecordsCallsTotal.CounterVec.WithLabelValues("false").Inc() } else { log.Debug("Records cache provider: using records list from cache") cachedRecordsCallsTotal.CounterVec.WithLabelValues("true").Inc() } return c.cache, nil } func (c *CachedProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { if !changes.HasChanges() { log.Info("Records cache provider: no changes to be applied") return nil } c.Reset() cachedApplyChangesCallsTotal.Counter.Inc() return c.Provider.ApplyChanges(ctx, changes) } func (c *CachedProvider) Reset() { c.cache = nil c.lastRead = time.Time{} } func (c *CachedProvider) needRefresh() bool { if c.cache == nil { log.Debug("Records cache provider is not initialized") return true } log.Debug("Records cache last Read: ", c.lastRead, "expiration: ", c.RefreshDelay, " provider expiration:", c.lastRead.Add(c.RefreshDelay), "expired: ", time.Now().After(c.lastRead.Add(c.RefreshDelay))) return time.Now().After(c.lastRead.Add(c.RefreshDelay)) } ================================================ FILE: provider/cached_provider_test.go ================================================ /* Copyright 2017 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 provider import ( "context" "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) type testProviderFunc struct { records func(ctx context.Context) ([]*endpoint.Endpoint, error) applyChanges func(ctx context.Context, changes *plan.Changes) error propertyValuesEqual func(name string, previous string, current string) bool adjustEndpoints func(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) getDomainFilter func() endpoint.DomainFilterInterface } func (p *testProviderFunc) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { return p.records(ctx) } func (p *testProviderFunc) ApplyChanges(ctx context.Context, changes *plan.Changes) error { return p.applyChanges(ctx, changes) } func (p *testProviderFunc) PropertyValuesEqual(name string, previous string, current string) bool { return p.propertyValuesEqual(name, previous, current) } func (p *testProviderFunc) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return p.adjustEndpoints(endpoints) } func (p *testProviderFunc) GetDomainFilter() endpoint.DomainFilterInterface { return p.getDomainFilter() } func recordsNotCalled(t *testing.T) func(ctx context.Context) ([]*endpoint.Endpoint, error) { return func(_ context.Context) ([]*endpoint.Endpoint, error) { t.Errorf("unexpected call to Records") return nil, nil } } func applyChangesNotCalled(t *testing.T) func(_ context.Context, _ *plan.Changes) error { return func(_ context.Context, _ *plan.Changes) error { t.Errorf("unexpected call to ApplyChanges") return nil } } func propertyValuesEqualNotCalled(t *testing.T) func(name string, previous string, current string) bool { return func(_ string, _ string, _ string) bool { t.Errorf("unexpected call to PropertyValuesEqual") return false } } func adjustEndpointsNotCalled(t *testing.T) func(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return func(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { t.Errorf("unexpected call to AdjustEndpoints") return endpoints, errors.New("unexpected call to AdjustEndpoints") } } func newTestProviderFunc(t *testing.T) *testProviderFunc { return &testProviderFunc{ records: recordsNotCalled(t), applyChanges: applyChangesNotCalled(t), propertyValuesEqual: propertyValuesEqualNotCalled(t), adjustEndpoints: adjustEndpointsNotCalled(t), } } func TestCachedProviderCallsProviderOnFirstCall(t *testing.T) { testProvider := newTestProviderFunc(t) testProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) { return []*endpoint.Endpoint{{DNSName: "domain.fqdn"}}, nil } provider := CachedProvider{ Provider: testProvider, } endpoints, err := provider.Records(t.Context()) assert.NoError(t, err) require.NotNil(t, endpoints) require.Len(t, endpoints, 1) require.NotNil(t, endpoints[0]) assert.Equal(t, "domain.fqdn", endpoints[0].DNSName) } func TestCachedProviderUsesCacheWhileValid(t *testing.T) { testProvider := newTestProviderFunc(t) testProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) { return []*endpoint.Endpoint{{DNSName: "domain.fqdn"}}, nil } provider := CachedProvider{ RefreshDelay: 30 * time.Second, Provider: testProvider, } _, err := provider.Records(t.Context()) require.NoError(t, err) t.Run("With consecutive calls within the caching time frame", func(t *testing.T) { testProvider.records = recordsNotCalled(t) endpoints, err := provider.Records(t.Context()) assert.NoError(t, err) require.NotNil(t, endpoints) require.Len(t, endpoints, 1) require.NotNil(t, endpoints[0]) assert.Equal(t, "domain.fqdn", endpoints[0].DNSName) }) t.Run("When the caching time frame is exceeded", func(t *testing.T) { testProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) { return []*endpoint.Endpoint{{DNSName: "new.domain.fqdn"}}, nil } provider.lastRead = time.Now().Add(-20 * time.Minute) endpoints, err := provider.Records(t.Context()) assert.NoError(t, err) require.NotNil(t, endpoints) require.Len(t, endpoints, 1) require.NotNil(t, endpoints[0]) assert.Equal(t, "new.domain.fqdn", endpoints[0].DNSName) }) } func TestCachedProviderForcesCacheRefreshOnUpdate(t *testing.T) { testProvider := newTestProviderFunc(t) testProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) { return []*endpoint.Endpoint{{DNSName: "domain.fqdn"}}, nil } provider := CachedProvider{ RefreshDelay: 30 * time.Second, Provider: testProvider, } _, err := provider.Records(t.Context()) require.NoError(t, err) t.Run("When empty changes are applied", func(t *testing.T) { testProvider.records = recordsNotCalled(t) testProvider.applyChanges = func(_ context.Context, _ *plan.Changes) error { return nil } err := provider.ApplyChanges(t.Context(), &plan.Changes{}) assert.NoError(t, err) t.Run("Next call to Records is cached", func(t *testing.T) { testProvider.applyChanges = applyChangesNotCalled(t) testProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) { return []*endpoint.Endpoint{{DNSName: "new.domain.fqdn"}}, nil } endpoints, err := provider.Records(t.Context()) assert.NoError(t, err) require.NotNil(t, endpoints) require.Len(t, endpoints, 1) require.NotNil(t, endpoints[0]) assert.Equal(t, "domain.fqdn", endpoints[0].DNSName) }) }) t.Run("When changes are applied", func(t *testing.T) { testProvider.records = recordsNotCalled(t) testProvider.applyChanges = func(_ context.Context, _ *plan.Changes) error { return nil } err := provider.ApplyChanges(t.Context(), &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "hello.world"}, }, }) assert.NoError(t, err) t.Run("Next call to Records is not cached", func(t *testing.T) { testProvider.applyChanges = applyChangesNotCalled(t) testProvider.records = func(_ context.Context) ([]*endpoint.Endpoint, error) { return []*endpoint.Endpoint{{DNSName: "new.domain.fqdn"}}, nil } endpoints, err := provider.Records(t.Context()) assert.NoError(t, err) require.NotNil(t, endpoints) require.Len(t, endpoints, 1) require.NotNil(t, endpoints[0]) assert.Equal(t, "new.domain.fqdn", endpoints[0].DNSName) }) }) } ================================================ FILE: provider/civo/civo.go ================================================ /* Copyright 2017 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 civo import ( "context" "fmt" "os" "strings" "github.com/civo/civogo" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) // CivoProvider is an implementation of Provider for Civo's DNS. type CivoProvider struct { provider.BaseProvider Client civogo.Client domainFilter *endpoint.DomainFilter DryRun bool } // CivoChanges All API calls calculated from the plan type CivoChanges struct { Creates []*CivoChangeCreate Deletes []*CivoChangeDelete Updates []*CivoChangeUpdate } // Empty returns true if there are no changes func (c *CivoChanges) Empty() bool { return len(c.Creates) == 0 && len(c.Updates) == 0 && len(c.Deletes) == 0 } // CivoChangeCreate Civo Domain Record Creates type CivoChangeCreate struct { Domain civogo.DNSDomain Options *civogo.DNSRecordConfig } // CivoChangeUpdate Civo Domain Record Updates type CivoChangeUpdate struct { Domain civogo.DNSDomain DomainRecord civogo.DNSRecord Options civogo.DNSRecordConfig } // CivoChangeDelete Civo Domain Record Deletes type CivoChangeDelete struct { Domain civogo.DNSDomain DomainRecord civogo.DNSRecord } // New creates a Civo provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(domainFilter, cfg.DryRun) } // newProvider initializes a new Civo DNS based Provider. func newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*CivoProvider, error) { token, ok := os.LookupEnv("CIVO_TOKEN") if !ok { return nil, fmt.Errorf("no token found") } // Declare a default region just for the client is not used for anything else // as the DNS API is global and not region based region := "LON1" civoClient, err := civogo.NewClient(token, region) if err != nil { return nil, err } userAgent := &civogo.Component{ Name: externaldns.UserAgentProduct, Version: externaldns.Version, } civoClient.SetUserAgent(userAgent) provider := &CivoProvider{ Client: *civoClient, domainFilter: domainFilter, DryRun: dryRun, } return provider, nil } // Zones returns the list of hosted zones. func (p *CivoProvider) Zones(_ context.Context) ([]civogo.DNSDomain, error) { zones, err := p.fetchZones() if err != nil { return nil, err } return zones, nil } // Records returns the list of records in a given zone. func (p *CivoProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.Zones(ctx) if err != nil { return nil, err } var endpoints []*endpoint.Endpoint for _, zone := range zones { records, err := p.fetchRecords(zone.ID) if err != nil { return nil, err } for _, r := range records { toUpper := strings.ToUpper(string(r.Type)) if provider.SupportedRecordType(toUpper) { name := fmt.Sprintf("%s.%s", r.Name, zone.Name) // root name is identified by the empty string and should be // translated to zone name for the endpoint entry. if r.Name == "" { name = zone.Name } endpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, toUpper, endpoint.TTL(r.TTL), r.Value)) } } } return endpoints, nil } func (p *CivoProvider) fetchRecords(domainID string) ([]civogo.DNSRecord, error) { records, err := p.Client.ListDNSRecords(domainID) if err != nil { return nil, err } return records, nil } func (p *CivoProvider) fetchZones() ([]civogo.DNSDomain, error) { var zones []civogo.DNSDomain allZones, err := p.Client.ListDNSDomains() if err != nil { return nil, err } for _, zone := range allZones { if !p.domainFilter.Match(zone.Name) { continue } zones = append(zones, zone) } return zones, nil } // submitChanges takes a zone and a collection of Changes and sends them as a single transaction. func (p *CivoProvider) submitChanges(changes CivoChanges) error { if changes.Empty() { log.Info("All records are already up to date") return nil } for _, change := range changes.Creates { logFields := log.Fields{ "Type": change.Options.Type, "Name": change.Options.Name, "Value": change.Options.Value, "Priority": change.Options.Priority, "TTL": change.Options.TTL, "action": "Create", } log.WithFields(logFields).Info("Creating record.") if p.DryRun { log.WithFields(logFields).Info("Would create record.") } else if _, err := p.Client.CreateDNSRecord(change.Domain.ID, change.Options); err != nil { log.WithFields(logFields).Errorf( "Failed to Create record: %v", err, ) } } for _, change := range changes.Deletes { logFields := log.Fields{ "Type": change.DomainRecord.Type, "Name": change.DomainRecord.Name, "Value": change.DomainRecord.Value, "Priority": change.DomainRecord.Priority, "TTL": change.DomainRecord.TTL, "action": "Delete", } log.WithFields(logFields).Info("Deleting record.") if p.DryRun { log.WithFields(logFields).Info("Would delete record.") } else if _, err := p.Client.DeleteDNSRecord(&change.DomainRecord); err != nil { log.WithFields(logFields).Errorf( "Failed to Delete record: %v", err, ) } } for _, change := range changes.Updates { logFields := log.Fields{ "Type": change.DomainRecord.Type, "Name": change.DomainRecord.Name, "Value": change.DomainRecord.Value, "Priority": change.DomainRecord.Priority, "TTL": change.DomainRecord.TTL, "action": "Update", } log.WithFields(logFields).Info("Updating record.") if p.DryRun { log.WithFields(logFields).Info("Would update record.") } else if _, err := p.Client.UpdateDNSRecord(&change.DomainRecord, &change.Options); err != nil { log.WithFields(logFields).Errorf( "Failed to Update record: %v", err, ) } } return nil } // processCreateActions return a list of changes to create records. func processCreateActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, createsByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error { for zoneID, creates := range createsByZone { zone := zonesByID[zoneID] if len(creates) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "zoneName": zone.Name, }).Info("Skipping Zone, no creates found.") continue } records := recordsByZoneID[zoneID] // Generate Create for _, ep := range creates { matchedRecords := getRecordID(records, zone, *ep) if len(matchedRecords) != 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "zoneName": zone.Name, "dnsName": ep.DNSName, "recordType": ep.RecordType, }).Warn("Records found which should not exist") } recordType, err := convertRecordType(ep.RecordType) if err != nil { return err } for _, target := range ep.Targets { civoChange.Creates = append(civoChange.Creates, &CivoChangeCreate{ Domain: zone, Options: &civogo.DNSRecordConfig{ Value: target, Name: getStrippedRecordName(zone, *ep), Type: recordType, Priority: 0, TTL: int(ep.RecordTTL), }, }) } } } return nil } // processUpdateActions return a list of changes to update records. func processUpdateActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, updatesByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error { for zoneID, updates := range updatesByZone { zone := zonesByID[zoneID] if len(updates) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "zoneName": zone.Name, }).Debug("Skipping Zone, no updates found.") continue } records := recordsByZoneID[zoneID] for _, ep := range updates { matchedRecords := getRecordID(records, zone, *ep) if len(matchedRecords) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Name, "recordType": ep.RecordType, }).Warn("Update Records not found.") } recordType, err := convertRecordType(ep.RecordType) if err != nil { return err } matchedRecordsByTarget := make(map[string]civogo.DNSRecord) for _, record := range matchedRecords { matchedRecordsByTarget[record.Value] = record } for _, target := range ep.Targets { if record, ok := matchedRecordsByTarget[target]; ok { log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Name, "recordType": ep.RecordType, "target": target, }).Warn("Updating Existing Target") civoChange.Updates = append(civoChange.Updates, &CivoChangeUpdate{ Domain: zone, DomainRecord: record, Options: civogo.DNSRecordConfig{ Value: target, Name: getStrippedRecordName(zone, *ep), Type: recordType, Priority: 0, TTL: int(ep.RecordTTL), }, }) delete(matchedRecordsByTarget, target) } else { // Record did not previously exist, create new 'target' log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Name, "recordType": ep.RecordType, "target": target, }).Warn("Creating New Target") civoChange.Creates = append(civoChange.Creates, &CivoChangeCreate{ Domain: zone, Options: &civogo.DNSRecordConfig{ Value: target, Name: getStrippedRecordName(zone, *ep), Type: recordType, Priority: 0, TTL: int(ep.RecordTTL), }, }) } } // Any remaining records have been removed, delete them for _, record := range matchedRecordsByTarget { log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "recordType": ep.RecordType, "target": record.Value, }).Warn("Deleting target") civoChange.Deletes = append(civoChange.Deletes, &CivoChangeDelete{ Domain: zone, DomainRecord: record, }) } } } return nil } // processDeleteActions return a list of changes to delete records. func processDeleteActions(zonesByID map[string]civogo.DNSDomain, recordsByZoneID map[string][]civogo.DNSRecord, deletesByZone map[string][]*endpoint.Endpoint, civoChange *CivoChanges) error { for zoneID, deletes := range deletesByZone { zone := zonesByID[zoneID] if len(deletes) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "zoneName": zone.Name, }).Debug("Skipping Zone, no deletes found.") continue } records := recordsByZoneID[zoneID] for _, ep := range deletes { matchedRecords := getRecordID(records, zone, *ep) if len(matchedRecords) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Name, "recordType": ep.RecordType, }).Warn("Records to Delete not found.") } for _, record := range matchedRecords { civoChange.Deletes = append(civoChange.Deletes, &CivoChangeDelete{ Domain: zone, DomainRecord: record, }) } } } return nil } // ApplyChanges applies a given set of changes in a given zone. func (p *CivoProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { var civoChange CivoChanges recordsByZoneID := make(map[string][]civogo.DNSRecord) zones, err := p.fetchZones() if err != nil { return err } zonesByID := make(map[string]civogo.DNSDomain) zoneNameIDMapper := provider.ZoneIDName{} for _, z := range zones { zoneNameIDMapper.Add(z.ID, z.Name) zonesByID[z.ID] = z } // Fetch records for each zone for _, zone := range zones { records, err := p.fetchRecords(zone.ID) if err != nil { return err } recordsByZoneID[zone.ID] = append(recordsByZoneID[zone.ID], records...) } createsByZone := endpointsByZone(zoneNameIDMapper, changes.Create) updatesByZone := endpointsByZone(zoneNameIDMapper, changes.UpdateNew) deletesByZone := endpointsByZone(zoneNameIDMapper, changes.Delete) // Generate Creates err = processCreateActions(zonesByID, recordsByZoneID, createsByZone, &civoChange) if err != nil { return err } // Generate Updates err = processUpdateActions(zonesByID, recordsByZoneID, updatesByZone, &civoChange) if err != nil { return err } // Generate Deletes err = processDeleteActions(zonesByID, recordsByZoneID, deletesByZone, &civoChange) if err != nil { return err } return p.submitChanges(civoChange) } func endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint { endpointsByZone := make(map[string][]*endpoint.Endpoint) for _, ep := range endpoints { zoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName) if zoneID == "" { log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", ep.DNSName) continue } endpointsByZone[zoneID] = append(endpointsByZone[zoneID], ep) } return endpointsByZone } func convertRecordType(recordType string) (civogo.DNSRecordType, error) { switch recordType { case "A": return civogo.DNSRecordTypeA, nil case "CNAME": return civogo.DNSRecordTypeCName, nil case "TXT": return civogo.DNSRecordTypeTXT, nil case "SRV": return civogo.DNSRecordTypeSRV, nil default: return "", fmt.Errorf("invalid Record Type: %s", recordType) } } func getStrippedRecordName(zone civogo.DNSDomain, ep endpoint.Endpoint) string { if ep.DNSName == zone.Name { return "" } return strings.TrimSuffix(ep.DNSName, "."+zone.Name) } func getRecordID(records []civogo.DNSRecord, zone civogo.DNSDomain, ep endpoint.Endpoint) []civogo.DNSRecord { var matchedRecords []civogo.DNSRecord for _, record := range records { stripedName := getStrippedRecordName(zone, ep) toUpper := strings.ToUpper(string(record.Type)) if record.Name == stripedName && toUpper == ep.RecordType { matchedRecords = append(matchedRecords, record) } } return matchedRecords } ================================================ FILE: provider/civo/civo_test.go ================================================ /* Copyright 2020 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 civo import ( "fmt" "io" "os" "reflect" "strings" "testing" "github.com/civo/civogo" "github.com/google/go-cmp/cmp" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) func TestNewProvider(t *testing.T) { t.Setenv("CIVO_TOKEN", "xxxxxxxxxxxxxxx") _, err := newProvider(endpoint.NewDomainFilter([]string{"test.civo.com"}), true) require.NoError(t, err) _ = os.Unsetenv("CIVO_TOKEN") } func TestNewCivoProviderNoToken(t *testing.T) { _, err := newProvider(endpoint.NewDomainFilter([]string{"test.civo.com"}), true) assert.Error(t, err) assert.Equal(t, "no token found", err.Error()) } func TestCivoProviderZones(t *testing.T) { client, server, _ := civogo.NewClientForTesting(map[string]string{ "/v2/dns": `[ {"id": "12345", "account_id": "1", "name": "example.com"}, {"id": "12346", "account_id": "1", "name": "example.net"} ]`, }) defer server.Close() provider := &CivoProvider{ Client: *client, } expected, err := client.ListDNSDomains() assert.NoError(t, err) zones, err := provider.Zones(t.Context()) assert.NoError(t, err) // Check if the return is a DNSDomain type assert.ElementsMatch(t, zones, expected) } func TestCivoProviderZonesWithError(t *testing.T) { client, server, _ := civogo.NewClientForTesting(map[string]string{ "/v2/dns-error": `[]`, }) defer server.Close() provider := &CivoProvider{ Client: *client, } _, err := provider.Zones(t.Context()) assert.Error(t, err) } func TestCivoProviderRecords(t *testing.T) { client, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{ { Method: "GET", Value: []civogo.ValueAdvanceClientForTesting{ { RequestBody: ``, URL: "/v2/dns/12345/records", ResponseBody: `[ {"id": "1", "domain_id":"12345", "account_id": "1", "name": "www", "type": "A", "value": "10.0.0.0", "ttl": 600}, {"id": "2", "account_id": "1", "domain_id":"12345", "name": "mail", "type": "A", "value": "10.0.0.1", "ttl": 600} ]`, }, { RequestBody: ``, URL: "/v2/dns", ResponseBody: `[ {"id": "12345", "account_id": "1", "name": "example.com"}, {"id": "12346", "account_id": "1", "name": "example.net"} ]`, }, }, }, }) defer server.Close() provider := &CivoProvider{ Client: *client, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } expected, err := client.ListDNSRecords("12345") assert.NoError(t, err) records, err := provider.Records(t.Context()) assert.NoError(t, err) assert.Equal(t, strings.TrimSuffix(records[0].DNSName, ".example.com"), expected[0].Name) assert.Equal(t, records[0].RecordType, string(expected[0].Type)) assert.Equal(t, int(records[0].RecordTTL), expected[0].TTL) assert.Equal(t, strings.TrimSuffix(records[1].DNSName, ".example.com"), expected[1].Name) assert.Equal(t, records[1].RecordType, string(expected[1].Type)) assert.Equal(t, int(records[1].RecordTTL), expected[1].TTL) } func TestCivoProviderRecordsWithError(t *testing.T) { client, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{ { Method: "GET", Value: []civogo.ValueAdvanceClientForTesting{ { RequestBody: ``, URL: "/v2/dns/12345/records", ResponseBody: `[ {"id": "1", "domain_id":"12345", "account_id": "1", "name": "", "type": "A", "value": "10.0.0.0", "ttl": 600}, {"id": "2", "account_id": "1", "domain_id":"12345", "name": "", "type": "A", "value": "10.0.0.1", "ttl": 600} ]`, }, { RequestBody: ``, URL: "/v2/dns", ResponseBody: `invalid-json-data`, }, }, }, }) defer server.Close() provider := &CivoProvider{ Client: *client, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } _, err := client.ListDNSRecords("12345") assert.NoError(t, err) endpoint, err := provider.Records(t.Context()) assert.Error(t, err) assert.Nil(t, endpoint) } func TestCivoProviderWithoutRecords(t *testing.T) { client, server, _ := civogo.NewClientForTesting(map[string]string{ "/v2/dns/12345/records": `[]`, "/v2/dns": `[ {"id": "12345", "account_id": "1", "name": "example.com"}, {"id": "12346", "account_id": "1", "name": "example.net"} ]`, }) defer server.Close() provider := &CivoProvider{ Client: *client, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } records, err := provider.Records(t.Context()) assert.NoError(t, err) assert.Empty(t, records) } func TestCivoProcessCreateActionsLogs(t *testing.T) { log.SetOutput(io.Discard) t.Run("Logs Skipping Zone, no creates found", func(t *testing.T) { zonesByID := map[string]civogo.DNSDomain{ "example.com": { ID: "1", AccountID: "1", Name: "example.com", }, } recordsByZoneID := map[string][]civogo.DNSRecord{ "example.com": { { ID: "1", AccountID: "1", Name: "abc", Value: "12.12.12.1", Type: "A", TTL: 600, }, }, } updateByZone := map[string][]*endpoint.Endpoint{ "example.com": { endpoint.NewEndpoint("abc.example.com", endpoint.RecordTypeA, "1.2.3.4"), }, } var civoChanges CivoChanges err := processCreateActions(zonesByID, recordsByZoneID, updateByZone, &civoChanges) require.NoError(t, err) assert.Len(t, civoChanges.Creates, 1) assert.Empty(t, civoChanges.Deletes) assert.Empty(t, civoChanges.Updates) }) t.Run("Records found which should not exist", func(t *testing.T) { zonesByID := map[string]civogo.DNSDomain{ "example.com": { ID: "1", AccountID: "1", Name: "example.com", }, } recordsByZoneID := map[string][]civogo.DNSRecord{ "example.com": {}, } updateByZone := map[string][]*endpoint.Endpoint{ "example.com": {}, } var civoChanges CivoChanges err := processCreateActions(zonesByID, recordsByZoneID, updateByZone, &civoChanges) require.NoError(t, err) assert.Empty(t, civoChanges.Creates) assert.Empty(t, civoChanges.Creates) assert.Empty(t, civoChanges.Updates) }) } func TestCivoProcessCreateActions(t *testing.T) { zoneByID := map[string]civogo.DNSDomain{ "example.com": { ID: "1", AccountID: "1", Name: "example.com", }, } recordsByZoneID := map[string][]civogo.DNSRecord{ "example.com": { { ID: "1", AccountID: "1", DNSDomainID: "1", Name: "txt", Value: "12.12.12.1", Type: "A", TTL: 600, }, }, } createsByZone := map[string][]*endpoint.Endpoint{ "example.com": { endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("txt.example.com", endpoint.RecordTypeCNAME, "foo.example.com"), }, } var changes CivoChanges err := processCreateActions(zoneByID, recordsByZoneID, createsByZone, &changes) require.NoError(t, err) assert.Len(t, changes.Creates, 2) assert.Empty(t, changes.Updates) assert.Empty(t, changes.Deletes) expectedCreates := []*CivoChangeCreate{ { Domain: civogo.DNSDomain{ ID: "1", AccountID: "1", Name: "example.com", }, Options: &civogo.DNSRecordConfig{ Type: "A", Name: "foo", Value: "1.2.3.4", }, }, { Domain: civogo.DNSDomain{ ID: "1", AccountID: "1", Name: "example.com", }, Options: &civogo.DNSRecordConfig{ Type: "CNAME", Name: "txt", Value: "foo.example.com", }, }, } if !elementsMatch(t, expectedCreates, changes.Creates) { assert.Failf(t, "diff: %s", cmp.Diff(expectedCreates, changes.Creates)) } } func TestCivoProcessCreateActionsWithError(t *testing.T) { zoneByID := map[string]civogo.DNSDomain{ "example.com": { ID: "1", AccountID: "1", Name: "example.com", }, } recordsByZoneID := map[string][]civogo.DNSRecord{ "example.com": { { ID: "1", AccountID: "1", DNSDomainID: "1", Name: "txt", Value: "12.12.12.1", Type: "A", TTL: 600, }, }, } createsByZone := map[string][]*endpoint.Endpoint{ "example.com": { endpoint.NewEndpoint("foo.example.com", "AAAA", "1.2.3.4"), endpoint.NewEndpoint("txt.example.com", endpoint.RecordTypeCNAME, "foo.example.com"), }, } var changes CivoChanges err := processCreateActions(zoneByID, recordsByZoneID, createsByZone, &changes) require.Error(t, err) assert.Equal(t, "invalid Record Type: AAAA", err.Error()) } func TestCivoProcessUpdateActionsWithError(t *testing.T) { log.SetOutput(io.Discard) zoneByID := map[string]civogo.DNSDomain{ "example.com": { ID: "1", AccountID: "1", Name: "example.com", }, } recordsByZoneID := map[string][]civogo.DNSRecord{ "example.com": { { ID: "1", AccountID: "1", DNSDomainID: "1", Name: "txt", Value: "12.12.12.1", Type: "A", TTL: 600, }, }, } updatesByZone := map[string][]*endpoint.Endpoint{ "example.com": { endpoint.NewEndpoint("foo.example.com", "AAAA", "1.2.3.4"), endpoint.NewEndpoint("txt.example.com", endpoint.RecordTypeCNAME, "foo.example.com"), }, } var changes CivoChanges err := processUpdateActions(zoneByID, recordsByZoneID, updatesByZone, &changes) require.Error(t, err) } func TestCivoProcessUpdateActions(t *testing.T) { zoneByID := map[string]civogo.DNSDomain{ "example.com": { ID: "1", AccountID: "1", Name: "example.com", }, } recordsByZoneID := map[string][]civogo.DNSRecord{ "example.com": { { ID: "1", AccountID: "1", DNSDomainID: "1", Name: "txt", Value: "1.2.3.4", Type: "A", TTL: 600, }, { ID: "2", AccountID: "1", DNSDomainID: "1", Name: "foo", Value: "foo.example.com", Type: "CNAME", TTL: 600, }, { ID: "3", AccountID: "1", DNSDomainID: "1", Name: "bar", Value: "10.10.10.1", Type: "A", TTL: 600, }, }, } updatesByZone := map[string][]*endpoint.Endpoint{ "example.com": { endpoint.NewEndpoint("txt.example.com", endpoint.RecordTypeA, "10.20.30.40"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeCNAME, "bar.example.com"), }, } var changes CivoChanges err := processUpdateActions(zoneByID, recordsByZoneID, updatesByZone, &changes) require.NoError(t, err) assert.Len(t, changes.Creates, 2) assert.Empty(t, changes.Updates) assert.Len(t, changes.Deletes, 2) expectedUpdate := []*CivoChangeCreate{ { Domain: civogo.DNSDomain{ ID: "1", AccountID: "1", Name: "example.com", }, Options: &civogo.DNSRecordConfig{ Type: "A", Name: "txt", Value: "10.20.30.40", }, }, { Domain: civogo.DNSDomain{ ID: "1", AccountID: "1", Name: "example.com", }, Options: &civogo.DNSRecordConfig{ Type: "CNAME", Name: "foo", Value: "bar.example.com", }, }, } if !elementsMatch(t, expectedUpdate, changes.Creates) { assert.Failf(t, "diff: %s", cmp.Diff(expectedUpdate, changes.Creates)) } expectedDelete := []*CivoChangeDelete{ { Domain: civogo.DNSDomain{ ID: "1", AccountID: "1", Name: "example.com", }, DomainRecord: civogo.DNSRecord{ ID: "1", AccountID: "1", DNSDomainID: "1", Name: "txt", Value: "1.2.3.4", Type: "A", Priority: 0, TTL: 600, }, }, { Domain: civogo.DNSDomain{ ID: "1", AccountID: "1", Name: "example.com", }, DomainRecord: civogo.DNSRecord{ ID: "2", AccountID: "1", DNSDomainID: "1", Name: "foo", Value: "foo.example.com", Type: "CNAME", Priority: 0, TTL: 600, }, }, } if !elementsMatch(t, expectedDelete, changes.Deletes) { assert.Failf(t, "diff: %s", cmp.Diff(expectedDelete, changes.Deletes)) } } func TestCivoProcessDeleteAction(t *testing.T) { zoneByID := map[string]civogo.DNSDomain{ "example.com": { ID: "1", AccountID: "1", Name: "example.com", }, } recordsByZoneID := map[string][]civogo.DNSRecord{ "example.com": { { ID: "1", AccountID: "1", DNSDomainID: "1", Name: "txt", Value: "1.2.3.4", Type: "A", TTL: 600, }, { ID: "2", AccountID: "1", DNSDomainID: "1", Name: "foo", Value: "5.6.7.8", Type: "A", TTL: 600, }, { ID: "3", AccountID: "1", DNSDomainID: "1", Name: "bar", Value: "10.10.10.1", Type: "A", TTL: 600, }, }, } deleteByDomain := map[string][]*endpoint.Endpoint{ "example.com": { endpoint.NewEndpoint("txt.example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "5.6.7.8"), }, } var changes CivoChanges err := processDeleteActions(zoneByID, recordsByZoneID, deleteByDomain, &changes) require.NoError(t, err) assert.Empty(t, changes.Creates) assert.Empty(t, changes.Updates) assert.Len(t, changes.Deletes, 2) expectedDelete := []*CivoChangeDelete{ { Domain: civogo.DNSDomain{ ID: "1", AccountID: "1", Name: "example.com", }, DomainRecord: civogo.DNSRecord{ ID: "1", AccountID: "1", DNSDomainID: "1", Name: "txt", Value: "1.2.3.4", Type: "A", TTL: 600, }, }, { Domain: civogo.DNSDomain{ ID: "1", AccountID: "1", Name: "example.com", }, DomainRecord: civogo.DNSRecord{ ID: "2", AccountID: "1", DNSDomainID: "1", Type: "A", Name: "foo", Value: "5.6.7.8", TTL: 600, }, }, } if !elementsMatch(t, expectedDelete, changes.Deletes) { assert.Failf(t, "diff: %s", cmp.Diff(expectedDelete, changes.Deletes)) } } func TestCivoApplyChanges(t *testing.T) { log.SetOutput(io.Discard) client, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{ { Method: "GET", Value: []civogo.ValueAdvanceClientForTesting{ { RequestBody: "", URL: "/v2/dns", ResponseBody: `[{"id": "12345", "account_id": "1", "name": "example.com"}]`, }, { RequestBody: "", URL: "/v2/dns/12345/records", ResponseBody: `[]`, }, }, }, }) defer server.Close() changes := &plan.Changes{} provider := &CivoProvider{ Client: *client, } changes.Create = []*endpoint.Endpoint{ {DNSName: "new.ext-dns-test.example.com", Targets: endpoint.Targets{"target"}, RecordType: endpoint.RecordTypeA}, {DNSName: "new.ext-dns-test-with-ttl.example.com", Targets: endpoint.Targets{"target"}, RecordType: endpoint.RecordTypeA, RecordTTL: 100}, } changes.Delete = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"target"}}} changes.UpdateOld = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.example.de", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"target-old"}}} changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.foo.com", Targets: endpoint.Targets{"target-new"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 100}} err := provider.ApplyChanges(t.Context(), changes) assert.NoError(t, err) } func TestCivoApplyChangesError(t *testing.T) { log.SetOutput(io.Discard) client, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{ { Method: "GET", Value: []civogo.ValueAdvanceClientForTesting{ { RequestBody: "", URL: "/v2/dns", ResponseBody: `[{"id": "12345", "account_id": "1", "name": "example.com"}]`, }, { RequestBody: "", URL: "/v2/dns/12345/records", ResponseBody: `[]`, }, }, }, }) defer server.Close() provider := &CivoProvider{ Client: *client, } cases := []struct { Name string changes *plan.Changes }{ { Name: "invalid record type from processCreateActions", changes: &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("bad.example.com", "AAAA", "1.2.3.4"), }, }, }, { Name: "invalid record type from processUpdateActions", changes: &plan.Changes{ UpdateOld: []*endpoint.Endpoint{ endpoint.NewEndpoint("bad.example.com", "AAAA", "1.2.3.4"), }, UpdateNew: []*endpoint.Endpoint{ endpoint.NewEndpoint("bad.example.com", "AAAA", "5.6.7.8"), }, }, }, } for _, tt := range cases { t.Run(tt.Name, func(t *testing.T) { err := provider.ApplyChanges(t.Context(), tt.changes) assert.Equal(t, "invalid Record Type: AAAA", string(err.Error())) }) } } func TestCivoProviderFetchZones(t *testing.T) { client, server, _ := civogo.NewClientForTesting(map[string]string{ "/v2/dns": `[ {"id": "12345", "account_id": "1", "name": "example.com"}, {"id": "12346", "account_id": "1", "name": "example.net"} ]`, }) defer server.Close() provider := &CivoProvider{ Client: *client, } expected, err := client.ListDNSDomains() if err != nil { t.Errorf("should not fail, %s", err) } zones, err := provider.fetchZones() if err != nil { t.Fatal(err) } assert.ElementsMatch(t, zones, expected) } func TestCivoProviderFetchZonesWithFilter(t *testing.T) { client, server, _ := civogo.NewClientForTesting(map[string]string{ "/v2/dns": `[ {"id": "12345", "account_id": "1", "name": "example.com"}, {"id": "12346", "account_id": "1", "name": "example.net"} ]`, }) defer server.Close() provider := &CivoProvider{ Client: *client, domainFilter: endpoint.NewDomainFilter([]string{".com"}), } expected := []civogo.DNSDomain{ {ID: "12345", Name: "example.com", AccountID: "1"}, } actual, err := provider.fetchZones() if err != nil { t.Fatal(err) } assert.ElementsMatch(t, expected, actual) } func TestCivoProviderFetchRecords(t *testing.T) { client, server, _ := civogo.NewClientForTesting(map[string]string{ "/v2/dns/12345/records": `[ {"id": "1", "domain_id":"12345", "account_id": "1", "name": "www", "type": "A", "value": "10.0.0.0", "ttl": 600}, {"id": "2", "account_id": "1", "domain_id":"12345", "name": "mail", "type": "A", "value": "10.0.0.1", "ttl": 600} ]`, }) defer server.Close() provider := &CivoProvider{ Client: *client, } expected, err := client.ListDNSRecords("12345") assert.NoError(t, err) actual, err := provider.fetchRecords("12345") assert.NoError(t, err) assert.ElementsMatch(t, expected, actual) } func TestCivoProviderFetchRecordsWithError(t *testing.T) { log.SetOutput(io.Discard) client, server, _ := civogo.NewClientForTesting(map[string]string{ "/v2/dns/12345/records": `[ {"id": "1", "domain_id":"12345", "account_id": "1", "name": "www", "type": "A", "value": "10.0.0.0", "ttl": 600}, {"id": "2", "account_id": "1", "domain_id":"12345", "name": "mail", "type": "A", "value": "10.0.0.1", "ttl": 600} ]`, }) defer server.Close() provider := &CivoProvider{ Client: *client, } _, err := provider.fetchRecords("235698") assert.Error(t, err) } func TestCivo_getStrippedRecordName(t *testing.T) { assert.Empty(t, getStrippedRecordName(civogo.DNSDomain{ Name: "foo.com", }, endpoint.Endpoint{ DNSName: "foo.com", })) assert.Equal(t, "api", getStrippedRecordName(civogo.DNSDomain{ Name: "foo.com", }, endpoint.Endpoint{ DNSName: "api.foo.com", })) } func TestCivo_convertRecordType(t *testing.T) { record, err := convertRecordType("A") recordA := civogo.DNSRecordType(civogo.DNSRecordTypeA) require.NoError(t, err) assert.Equal(t, recordA, record) record, err = convertRecordType("CNAME") recordCName := civogo.DNSRecordType(civogo.DNSRecordTypeCName) require.NoError(t, err) assert.Equal(t, recordCName, record) record, err = convertRecordType("TXT") recordTXT := civogo.DNSRecordType(civogo.DNSRecordTypeTXT) require.NoError(t, err) assert.Equal(t, recordTXT, record) record, err = convertRecordType("SRV") recordSRV := civogo.DNSRecordType(civogo.DNSRecordTypeSRV) require.NoError(t, err) assert.Equal(t, recordSRV, record) _, err = convertRecordType("INVALID") require.Error(t, err) assert.Equal(t, "invalid Record Type: INVALID", err.Error()) } func TestCivoProviderGetRecordID(t *testing.T) { zone := civogo.DNSDomain{ ID: "12345", Name: "test.com", } record := []civogo.DNSRecord{{ ID: "1", Type: "A", Name: "www", Value: "10.0.0.0", DNSDomainID: "12345", TTL: 600, }, { ID: "2", Type: "A", Name: "api", Value: "10.0.0.1", DNSDomainID: "12345", TTL: 600, }} endPoint := endpoint.Endpoint{DNSName: "www.test.com", Targets: endpoint.Targets{"10.0.0.0"}, RecordType: "A"} id := getRecordID(record, zone, endPoint) assert.Equal(t, id[0].ID, record[0].ID) } func TestCivo_submitChangesCreate(t *testing.T) { log.SetOutput(io.Discard) client, server, _ := civogo.NewAdvancedClientForTesting([]civogo.ConfigAdvanceClientForTesting{ { Method: "POST", Value: []civogo.ValueAdvanceClientForTesting{ { RequestBody: `{"type":"MX","name":"mail","value":"10.0.0.1","priority":10,"ttl":600}`, URL: "/v2/dns/12345/records", ResponseBody: `{ "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", "account_id": "1", "domain_id": "12345", "name": "mail", "value": "10.0.0.1", "type": "MX", "priority": 10, "ttl": 600 }`, }, }, }, { Method: "DELETE", Value: []civogo.ValueAdvanceClientForTesting{ { URL: "/v2/dns/12345/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3", ResponseBody: `{"result": "success"}`, }, { URL: "/v2/dns/12345/records/error-record-id", ResponseBody: `{"result": "error", "error": "failed to delete record"}`, }, }, }, { Method: "PUT", Value: []civogo.ValueAdvanceClientForTesting{ { RequestBody: `{"type":"MX","name":"mail","value":"10.0.0.2","priority":10,"ttl":600}`, URL: "/v2/dns/12345/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3", ResponseBody: `{ "id": "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", "account_id": "1", "domain_id": "12345", "name": "mail", "value": "10.0.0.2", "type": "MX", "priority": 10, "ttl": 600 }`, }, { RequestBody: `{"type":"MX","name":"mail","value":"10.0.0.3","priority":10,"ttl":600}`, URL: "/v2/dns/12345/records/error-record-id", ResponseBody: `{"result": "error", "error": "failed to update record"}`, }, }, }, }) defer server.Close() provider := &CivoProvider{ Client: *client, DryRun: true, } cases := []struct { name string changes *CivoChanges expectedResult error }{ { name: "changes slice is empty", changes: &CivoChanges{}, expectedResult: nil, }, { name: "changes slice has changes and update changes", changes: &CivoChanges{ Creates: []*CivoChangeCreate{ { Domain: civogo.DNSDomain{ ID: "12345", AccountID: "1", Name: "example.com", }, Options: &civogo.DNSRecordConfig{ Type: "MX", Name: "mail", Value: "10.0.0.1", Priority: 10, TTL: 600, }, }, }, Updates: []*CivoChangeUpdate{ { Domain: civogo.DNSDomain{ ID: "12345", AccountID: "2", Name: "example.org", }, }, { Domain: civogo.DNSDomain{ ID: "67890", AccountID: "3", Name: "example.COM", }, }, }, }, expectedResult: nil, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { err := provider.submitChanges(*c.changes) assert.NoError(t, err) }) } } func TestCivo_submitChangesDelete(t *testing.T) { client, server, _ := civogo.NewClientForTesting(map[string]string{ "/v2/dns/12345/records/76cc107f-fbef-4e2b-b97f-f5d34f4075d3": `{"result": "success"}`, }) defer server.Close() provider := &CivoProvider{ Client: *client, DryRun: false, } changes := CivoChanges{ Deletes: []*CivoChangeDelete{ { Domain: civogo.DNSDomain{ID: "12345", AccountID: "1", Name: "example.com"}, DomainRecord: civogo.DNSRecord{ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", AccountID: "1", DNSDomainID: "12345", Name: "mail", Value: "10.0.0.2", Type: "MX", Priority: 10, TTL: 600, }, }, }, } err := provider.submitChanges(changes) assert.NoError(t, err) } func TestCivoChangesEmpty(t *testing.T) { // Test empty CivoChanges c := &CivoChanges{} assert.True(t, c.Empty()) // Test CivoChanges with Creates c = &CivoChanges{ Creates: []*CivoChangeCreate{ { Domain: civogo.DNSDomain{ ID: "12345", AccountID: "1", Name: "example.com", }, Options: &civogo.DNSRecordConfig{ Type: civogo.DNSRecordTypeA, Name: "www", Value: "192.1.1.1", Priority: 0, TTL: 600, }, }, }, } assert.False(t, c.Empty()) // Test CivoChanges with Updates c = &CivoChanges{ Updates: []*CivoChangeUpdate{ { Domain: civogo.DNSDomain{ ID: "12345", AccountID: "1", Name: "example.com", }, DomainRecord: civogo.DNSRecord{ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", AccountID: "1", DNSDomainID: "12345", Name: "mail", Value: "192.168.1.2", Type: "MX", Priority: 10, TTL: 600, }, Options: civogo.DNSRecordConfig{ Type: "MX", Name: "mail", Value: "192.168.1.3", Priority: 10, TTL: 600, }, }, }, } assert.False(t, c.Empty()) // Test CivoChanges with Deletes c = &CivoChanges{ Deletes: []*CivoChangeDelete{ { Domain: civogo.DNSDomain{ ID: "12345", AccountID: "1", Name: "example.com", }, DomainRecord: civogo.DNSRecord{ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", AccountID: "1", DNSDomainID: "12345", Name: "mail", Value: "192.168.1.3", Type: "MX", Priority: 10, TTL: 600, }, }, }, } assert.False(t, c.Empty()) // Test CivoChanges with Creates, Updates, and Deletes c = &CivoChanges{ Creates: []*CivoChangeCreate{ { Domain: civogo.DNSDomain{ ID: "12345", AccountID: "1", Name: "example.com", }, Options: &civogo.DNSRecordConfig{ Type: civogo.DNSRecordTypeA, Name: "www", Value: "192.1.1.1", Priority: 0, TTL: 600, }, }, }, Updates: []*CivoChangeUpdate{ { Domain: civogo.DNSDomain{ ID: "12345", AccountID: "1", Name: "example.com", }, DomainRecord: civogo.DNSRecord{ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", AccountID: "1", DNSDomainID: "12345", Name: "mail", Value: "192.168.1.2", Type: "MX", Priority: 10, TTL: 600, }, Options: civogo.DNSRecordConfig{ Type: "MX", Name: "mail", Value: "192.168.1.3", Priority: 10, TTL: 600, }, }, }, Deletes: []*CivoChangeDelete{ { Domain: civogo.DNSDomain{ ID: "12345", AccountID: "1", Name: "example.com", }, DomainRecord: civogo.DNSRecord{ ID: "76cc107f-fbef-4e2b-b97f-f5d34f4075d3", AccountID: "1", DNSDomainID: "12345", Name: "mail", Value: "192.168.1.3", Type: "MX", Priority: 10, TTL: 600, }, }, }, } assert.False(t, c.Empty()) } // This function is an adapted copy of the testify package's ElementsMatch function with the // call to ObjectsAreEqual replaced with cmp.Equal which better handles struct's with pointers to // other structs. It also ignores ordering when comparing unlike cmp.Equal. func elementsMatch(t *testing.T, listA, listB any) bool { switch { case listA == nil && listB == nil: return true case listA == nil: return isEmpty(listB) case listB == nil: return isEmpty(listA) } aKind := reflect.TypeOf(listA).Kind() bKind := reflect.TypeOf(listB).Kind() if aKind != reflect.Array && aKind != reflect.Slice { return assert.Fail(t, fmt.Sprintf("%q has an unsupported type %s", listA, aKind)) } if bKind != reflect.Array && bKind != reflect.Slice { return assert.Fail(t, fmt.Sprintf("%q has an unsupported type %s", listB, bKind)) } aValue := reflect.ValueOf(listA) bValue := reflect.ValueOf(listB) aLen := aValue.Len() bLen := bValue.Len() if aLen != bLen { return assert.Fail(t, fmt.Sprintf("lengths don't match: %d != %d", aLen, bLen)) } // Mark indexes in bValue that we already used visited := make([]bool, bLen) for i := range aLen { element := aValue.Index(i).Interface() found := false for j := range bLen { if visited[j] { continue } if cmp.Equal(bValue.Index(j).Interface(), element) { visited[j] = true found = true break } } if !found { return assert.Fail(t, fmt.Sprintf("element %s appears more times in %s than in %s", element, aValue, bValue)) } } return true } func isEmpty(xs any) bool { if xs != nil { objValue := reflect.ValueOf(xs) return objValue.Len() == 0 } return true } ================================================ FILE: provider/cloudflare/OWNERS ================================================ approvers: - sheerun ================================================ FILE: provider/cloudflare/cloudflare.go ================================================ /* Copyright 2017 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 cloudflare import ( "context" "errors" "fmt" "io" "net/http" "os" "sort" "strconv" "strings" "time" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/addressing" "github.com/cloudflare/cloudflare-go/v5/custom_hostnames" "github.com/cloudflare/cloudflare-go/v5/dns" "github.com/cloudflare/cloudflare-go/v5/option" "github.com/cloudflare/cloudflare-go/v5/zones" log "github.com/sirupsen/logrus" "golang.org/x/net/publicsuffix" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/source/annotations" ) type changeAction int const ( // Environment variable names for CloudFlare authentication cfAPIEmailEnvKey = "CF_API_EMAIL" cfAPIKeyEnvKey = "CF_API_KEY" cfAPITokenEnvKey = "CF_API_TOKEN" // cloudFlareCreate is a ChangeAction enum value cloudFlareCreate changeAction = iota // cloudFlareDelete is a ChangeAction enum value cloudFlareDelete // cloudFlareUpdate is a ChangeAction enum value cloudFlareUpdate // defaultTTL 1 = automatic defaultTTL = 1 // Cloudflare tier limitations https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/#availability freeZoneMaxCommentLength = 100 paidZoneMaxCommentLength = 500 ) var changeActionNames = map[changeAction]string{ cloudFlareCreate: "CREATE", cloudFlareDelete: "DELETE", cloudFlareUpdate: "UPDATE", } func (action changeAction) String() string { return changeActionNames[action] } type DNSRecordIndex struct { Name string Type string Content string } type DNSRecordsMap map[DNSRecordIndex]dns.RecordResponse var recordTypeProxyNotSupported = map[string]bool{ "LOC": true, "MX": true, "NS": true, "SPF": true, "TXT": true, "SRV": true, } // cloudFlareDNS is the subset of the CloudFlare API that we actually use. Add methods as required. Signatures must match exactly. type cloudFlareDNS interface { ZoneIDByName(zoneName string) (string, error) ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] GetZone(ctx context.Context, zoneID string) (*zones.Zone, error) ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse] BatchDNSRecords(ctx context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error) CreateDNSRecord(ctx context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error) DeleteDNSRecord(ctx context.Context, recordID string, params dns.RecordDeleteParams) error UpdateDNSRecord(ctx context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error) ListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] CreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error UpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error DeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] DeleteCustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error CreateCustomHostname(ctx context.Context, zoneID string, ch customHostname) error } type zoneService struct { service *cloudflare.Client } func (z zoneService) ZoneIDByName(zoneName string) (string, error) { // Use v4 API to find zone by name params := zones.ZoneListParams{ Name: cloudflare.F(zoneName), } iter := z.service.Zones.ListAutoPaging(context.Background(), params) for zone := range autoPagerIterator(iter) { if zone.Name == zoneName { return zone.ID, nil } } if err := iter.Err(); err != nil { return "", fmt.Errorf("failed to list zones from CloudFlare API: %w", err) } return "", fmt.Errorf("zone %q not found in CloudFlare account - verify the zone exists and API credentials have access to it", zoneName) } func (z zoneService) CreateDNSRecord(ctx context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error) { return z.service.DNS.Records.New(ctx, params) } func (z zoneService) ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse] { return z.service.DNS.Records.ListAutoPaging(ctx, params) } func (z zoneService) UpdateDNSRecord(ctx context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error) { return z.service.DNS.Records.Update(ctx, recordID, params) } func (z zoneService) DeleteDNSRecord(ctx context.Context, recordID string, params dns.RecordDeleteParams) error { _, err := z.service.DNS.Records.Delete(ctx, recordID, params) return err } func (z zoneService) ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] { return z.service.Zones.ListAutoPaging(ctx, params) } func (z zoneService) GetZone(ctx context.Context, zoneID string) (*zones.Zone, error) { return z.service.Zones.Get(ctx, zones.ZoneGetParams{ZoneID: cloudflare.F(zoneID)}) } // listZonesV4Params returns the appropriate Zone List Params for v4 API func listZonesV4Params() zones.ZoneListParams { return zones.ZoneListParams{} } type DNSRecordsConfig struct { PerPage int Comment string BatchChangeSize int BatchChangeInterval time.Duration } func (c *DNSRecordsConfig) trimAndValidateComment(dnsName, comment string, paidZone func(string) bool) string { if len(comment) <= freeZoneMaxCommentLength { return comment } maxLength := freeZoneMaxCommentLength if paidZone(dnsName) { maxLength = paidZoneMaxCommentLength } if len(comment) > maxLength { log.Warnf("DNS record comment is invalid. Trimming comment of %s. To avoid endless syncs, please set it to less than %d chars.", dnsName, maxLength) return comment[:maxLength] } return comment } func (p *CloudFlareProvider) ZoneHasPaidPlan(hostname string) bool { zone, err := publicsuffix.EffectiveTLDPlusOne(hostname) if err != nil { log.Errorf("Failed to get effective TLD+1 for hostname %s %v", hostname, err) return false } zoneID, err := p.Client.ZoneIDByName(zone) if err != nil { log.Errorf("Failed to get zone %s by name %v", zone, err) return false } zoneDetails, err := p.Client.GetZone(context.Background(), zoneID) if err != nil { log.Errorf("Failed to get zone %s details %v", zone, err) return false } return zoneDetails.Plan.IsSubscribed //nolint:staticcheck // SA1019: Plan.IsSubscribed is deprecated but no replacement available yet } // CloudFlareProvider is an implementation of Provider for CloudFlare DNS. type CloudFlareProvider struct { provider.BaseProvider Client cloudFlareDNS // only consider hosted zones managing domains ending in this suffix domainFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter proxiedByDefault bool DryRun bool CustomHostnamesConfig CustomHostnamesConfig DNSRecordsConfig DNSRecordsConfig RegionalServicesConfig RegionalServicesConfig } // cloudFlareChange differentiates between ChangeActions type cloudFlareChange struct { Action changeAction ResourceRecord dns.RecordResponse RegionalHostname regionalHostname CustomHostnames map[string]customHostname CustomHostnamesPrev []string } func convertCloudflareError(err error) error { // Handle CloudFlare v5 SDK errors according to the documentation: // https://github.com/cloudflare/cloudflare-go?tab=readme-ov-file#errors var apierr *cloudflare.Error if errors.As(err, &apierr) { // Rate limit errors (429) and server errors (5xx) should be treated as soft errors // so that external-dns will retry them later if apierr.StatusCode == http.StatusTooManyRequests || apierr.StatusCode >= http.StatusInternalServerError { return provider.NewSoftError(err) } // For other structured API errors (4xx), return the error unchanged // Note: We must NOT call err.Error() on v5 cloudflare.Error types with nil internal fields return err } // Transport-level errors that the SDK does not wrap as *cloudflare.Error. // Both are transient and worth retrying at the external-dns level. // ErrUnexpectedEOF – connection closed mid-response (during body read) // EOF – connection closed before any response bytes arrived if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) { return provider.NewSoftError(err) } // The v5 SDK's retry logic and error wrapping can hide the structured error type, // so we need string matching to catch rate limits in wrapped errors like: // "exceeded available rate limit retries" from the SDK's auto-retry mechanism. errMsg := strings.ToLower(err.Error()) if strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "429") || strings.Contains(errMsg, "exceeded available rate limit retries") || strings.Contains(errMsg, "too many requests") { return provider.NewSoftError(err) } return err } // newProvider initializes a new CloudFlare DNS based Provider. func newProvider( domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, proxiedByDefault bool, dryRun bool, regionalServicesConfig RegionalServicesConfig, customHostnamesConfig CustomHostnamesConfig, dnsRecordsConfig DNSRecordsConfig, ) (*CloudFlareProvider, error) { // initialize via chosen auth method and returns new API object var client *cloudflare.Client token := os.Getenv(cfAPITokenEnvKey) if token != "" { if trimed, ok := strings.CutPrefix(token, "file:"); ok { tokenBytes, err := os.ReadFile(trimed) if err != nil { return nil, fmt.Errorf("failed to read %s from file: %w", cfAPITokenEnvKey, err) } token = strings.TrimSpace(string(tokenBytes)) } client = cloudflare.NewClient( option.WithAPIToken(token), ) } else { apiKey := os.Getenv(cfAPIKeyEnvKey) apiEmail := os.Getenv(cfAPIEmailEnvKey) if apiKey == "" || apiEmail == "" { return nil, fmt.Errorf("cloudflare credentials are not configured: set either %s or both %s and %s environment variables", cfAPITokenEnvKey, cfAPIKeyEnvKey, cfAPIEmailEnvKey) } client = cloudflare.NewClient( option.WithAPIKey(apiKey), option.WithAPIEmail(apiEmail), ) } if regionalServicesConfig.RegionKey != "" { regionalServicesConfig.Enabled = true } return &CloudFlareProvider{ Client: zoneService{client}, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, proxiedByDefault: proxiedByDefault, CustomHostnamesConfig: customHostnamesConfig, DryRun: dryRun, RegionalServicesConfig: regionalServicesConfig, DNSRecordsConfig: dnsRecordsConfig, }, nil } // New creates a Cloudflare provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider( domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.CloudflareProxied, cfg.DryRun, RegionalServicesConfig{ Enabled: cfg.CloudflareRegionalServices, RegionKey: cfg.CloudflareRegionKey, }, CustomHostnamesConfig{ Enabled: cfg.CloudflareCustomHostnames, MinTLSVersion: cfg.CloudflareCustomHostnamesMinTLSVersion, CertificateAuthority: cfg.CloudflareCustomHostnamesCertificateAuthority, }, DNSRecordsConfig{ PerPage: cfg.CloudflareDNSRecordsPerPage, Comment: cfg.CloudflareDNSRecordsComment, BatchChangeSize: cfg.BatchChangeSize, BatchChangeInterval: cfg.BatchChangeInterval, }, ) } // Zones returns the list of hosted zones. func (p *CloudFlareProvider) Zones(ctx context.Context) ([]zones.Zone, error) { var result []zones.Zone // if there is a zoneIDfilter configured // && if the filter isn't just a blank string (used in tests) if len(p.zoneIDFilter.ZoneIDs) > 0 && p.zoneIDFilter.ZoneIDs[0] != "" { log.Debugln("zoneIDFilter configured. only looking up zone IDs defined") for _, zoneID := range p.zoneIDFilter.ZoneIDs { log.Debugf("looking up zone %q", zoneID) detailResponse, err := p.Client.GetZone(ctx, zoneID) if err != nil { log.Errorf("zone %q lookup failed, %v", zoneID, err) return result, convertCloudflareError(err) } log.WithFields(log.Fields{ "zoneName": detailResponse.Name, "zoneID": detailResponse.ID, }).Debugln("adding zone for consideration") result = append(result, *detailResponse) } return result, nil } log.Debugln("no zoneIDFilter configured, looking at all zones") params := listZonesV4Params() iter := p.Client.ListZones(ctx, params) for zone := range autoPagerIterator(iter) { if !p.domainFilter.Match(zone.Name) { log.Debugf("zone %q not in domain filter", zone.Name) continue } log.WithFields(log.Fields{ "zoneName": zone.Name, "zoneID": zone.ID, }).Debugln("adding zone for consideration") result = append(result, zone) } if iter.Err() != nil { return nil, convertCloudflareError(iter.Err()) } return result, nil } // Records returns the list of records. func (p *CloudFlareProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.Zones(ctx) if err != nil { return nil, err } var endpoints []*endpoint.Endpoint for _, zone := range zones { records, err := p.getDNSRecordsMap(ctx, zone.ID) if err != nil { return nil, err } // nil if custom hostnames are not enabled chs, chErr := p.listCustomHostnamesWithPagination(ctx, zone.ID) if chErr != nil { return nil, chErr } // As CloudFlare does not support "sets" of targets, but instead returns // a single entry for each name/type/target, we have to group by name // and record to allow the planner to calculate the correct plan. See #992. zoneEndpoints := p.groupByNameAndTypeWithCustomHostnames(records, chs) if err := p.addEnpointsProviderSpecificRegionKeyProperty(ctx, zone.ID, zoneEndpoints); err != nil { return nil, err } endpoints = append(endpoints, zoneEndpoints...) } return endpoints, nil } // ApplyChanges applies a given set of changes in a given zone. func (p *CloudFlareProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { var cloudflareChanges []*cloudFlareChange // if custom hostnames are enabled, deleting first allows to avoid conflicts with the new ones if p.CustomHostnamesConfig.Enabled { for _, e := range changes.Delete { for _, target := range e.Targets { change, err := p.newCloudFlareChange(cloudFlareDelete, e, target, nil) if err != nil { log.Errorf("failed to create cloudflare change: %v", err) continue } cloudflareChanges = append(cloudflareChanges, change) } } } for _, e := range changes.Create { for _, target := range e.Targets { change, err := p.newCloudFlareChange(cloudFlareCreate, e, target, nil) if err != nil { log.Errorf("failed to create cloudflare change: %v", err) continue } cloudflareChanges = append(cloudflareChanges, change) } } for i, desired := range changes.UpdateNew { current := changes.UpdateOld[i] add, remove, leave := provider.Difference(current.Targets, desired.Targets) for _, a := range remove { change, err := p.newCloudFlareChange(cloudFlareDelete, current, a, current) if err != nil { log.Errorf("failed to create cloudflare change: %v", err) continue } cloudflareChanges = append(cloudflareChanges, change) } for _, a := range add { change, err := p.newCloudFlareChange(cloudFlareCreate, desired, a, current) if err != nil { log.Errorf("failed to create cloudflare change: %v", err) continue } cloudflareChanges = append(cloudflareChanges, change) } for _, a := range leave { change, err := p.newCloudFlareChange(cloudFlareUpdate, desired, a, current) if err != nil { log.Errorf("failed to create cloudflare change: %v", err) continue } cloudflareChanges = append(cloudflareChanges, change) } } // TODO: consider deleting before creating even if custom hostnames are not in use if !p.CustomHostnamesConfig.Enabled { for _, e := range changes.Delete { for _, target := range e.Targets { change, err := p.newCloudFlareChange(cloudFlareDelete, e, target, nil) if err != nil { log.Errorf("failed to create cloudflare change: %v", err) continue } cloudflareChanges = append(cloudflareChanges, change) } } } return p.submitChanges(ctx, cloudflareChanges) } // submitChanges takes a zone and a collection of Changes and sends them as a single transaction. func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloudFlareChange) error { // return early if there is nothing to change if len(changes) == 0 { log.Info("All records are already up to date") return nil } zones, err := p.Zones(ctx) if err != nil { return err } // separate into per-zone change sets to be passed to the API. changesByZone := p.changesByZone(zones, changes) var failedZones []string for zoneID, zoneChanges := range changesByZone { var failedChange bool for _, change := range zoneChanges { logFields := log.Fields{ "record": change.ResourceRecord.Name, "type": change.ResourceRecord.Type, "ttl": change.ResourceRecord.TTL, "action": change.Action.String(), "zone": zoneID, } log.WithFields(logFields).Info("Changing record.") } if p.DryRun { // In dry-run mode, skip all DNS record mutations but still process // regional hostname changes (which have their own dry-run logging). if p.RegionalServicesConfig.Enabled { desiredRegionalHostnames, err := desiredRegionalHostnames(zoneChanges) if err != nil { return fmt.Errorf("failed to build desired regional hostnames: %w", err) } if len(desiredRegionalHostnames) > 0 { regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID) if err != nil { return fmt.Errorf("could not fetch regional hostnames from zone, %w", err) } regionalHostnamesChanges := regionalHostnamesChanges(desiredRegionalHostnames, regionalHostnames) if !p.submitRegionalHostnameChanges(ctx, zoneID, regionalHostnamesChanges) { failedChange = true } } } if failedChange { failedZones = append(failedZones, zoneID) } continue } // Fetch the zone's current DNS records and custom hostnames once, rather // than once per change, to avoid O(n) API calls for n changes. records, err := p.getDNSRecordsMap(ctx, zoneID) if err != nil { return fmt.Errorf("could not fetch records from zone, %w", err) } chs, chErr := p.listCustomHostnamesWithPagination(ctx, zoneID) if chErr != nil { return fmt.Errorf("could not fetch custom hostnames from zone, %w", chErr) } // Apply custom hostname side-effects (separate Cloudflare API), then // classify DNS record changes into batch collections. if p.processCustomHostnameChanges(ctx, zoneID, zoneChanges, chs) { failedChange = true } bc := p.buildBatchCollections(zoneID, zoneChanges, records) if p.submitDNSRecordChanges(ctx, zoneID, bc, records) { failedChange = true } if p.RegionalServicesConfig.Enabled { desiredRegionalHostnames, err := desiredRegionalHostnames(zoneChanges) if err != nil { return fmt.Errorf("failed to build desired regional hostnames: %w", err) } if len(desiredRegionalHostnames) > 0 { regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID) if err != nil { return fmt.Errorf("could not fetch regional hostnames from zone, %w", err) } regionalHostnamesChanges := regionalHostnamesChanges(desiredRegionalHostnames, regionalHostnames) if !p.submitRegionalHostnameChanges(ctx, zoneID, regionalHostnamesChanges) { failedChange = true } } } if failedChange { failedZones = append(failedZones, zoneID) } } if len(failedZones) > 0 { return provider.NewSoftErrorf("failed to submit all changes for the following zones: %q", failedZones) } return nil } // parseTagsAnnotation is the single helper method to handle tags from the annotation string. // It splits the string, cleans up whitespace, and sorts the tags to create a canonical representation. func parseTagsAnnotation(tagString string) []string { tags := strings.Split(tagString, ",") cleanedTags := make([]string, 0, len(tags)) for _, tag := range tags { trimmed := strings.TrimSpace(tag) if trimmed != "" { cleanedTags = append(cleanedTags, trimmed) } } sort.Strings(cleanedTags) return cleanedTags } // AdjustEndpoints modifies the endpoints as needed by the specific provider func (p *CloudFlareProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { var adjustedEndpoints []*endpoint.Endpoint for _, e := range endpoints { proxied := shouldBeProxied(e, p.proxiedByDefault) if proxied { e.RecordTTL = 0 } e.SetProviderSpecificProperty(annotations.CloudflareProxiedKey, strconv.FormatBool(proxied)) if p.CustomHostnamesConfig.Enabled { // sort custom hostnames in annotation to properly detect changes if customHostnames := getEndpointCustomHostnames(e); len(customHostnames) > 1 { sort.Strings(customHostnames) e.SetProviderSpecificProperty(annotations.CloudflareCustomHostnameKey, strings.Join(customHostnames, ",")) } } else { // ignore custom hostnames annotations if not enabled e.DeleteProviderSpecificProperty(annotations.CloudflareCustomHostnameKey) } if val, ok := e.GetProviderSpecificProperty(annotations.CloudflareTagsKey); ok { sortedTags := parseTagsAnnotation(val) e.SetProviderSpecificProperty(annotations.CloudflareTagsKey, strings.Join(sortedTags, ",")) } p.adjustEndpointProviderSpecificRegionKeyProperty(e) if p.DNSRecordsConfig.Comment != "" { if _, found := e.GetProviderSpecificProperty(annotations.CloudflareRecordCommentKey); !found { e.SetProviderSpecificProperty(annotations.CloudflareRecordCommentKey, p.DNSRecordsConfig.Comment) } } adjustedEndpoints = append(adjustedEndpoints, e) } return adjustedEndpoints, nil } // changesByZone separates a multi-zone change into a single change per zone. func (p *CloudFlareProvider) changesByZone(zones []zones.Zone, changeSet []*cloudFlareChange) map[string][]*cloudFlareChange { changes := make(map[string][]*cloudFlareChange) zoneNameIDMapper := provider.ZoneIDName{} for _, z := range zones { zoneNameIDMapper.Add(z.ID, z.Name) changes[z.ID] = []*cloudFlareChange{} } for _, c := range changeSet { zoneID, _ := zoneNameIDMapper.FindZone(c.ResourceRecord.Name) if zoneID == "" { log.Debugf("Skipping record %q because no hosted zone matching record DNS Name was detected", c.ResourceRecord.Name) continue } changes[zoneID] = append(changes[zoneID], c) } return changes } func (p *CloudFlareProvider) getRecordID(records DNSRecordsMap, record dns.RecordResponse) string { if zoneRecord, ok := records[DNSRecordIndex{Name: record.Name, Type: string(record.Type), Content: record.Content}]; ok { return zoneRecord.ID } return "" } func (p *CloudFlareProvider) newCloudFlareChange(action changeAction, ep *endpoint.Endpoint, target string, current *endpoint.Endpoint) (*cloudFlareChange, error) { ttl := dns.TTL(defaultTTL) proxied := shouldBeProxied(ep, p.proxiedByDefault) if ep.RecordTTL.IsConfigured() { ttl = dns.TTL(ep.RecordTTL) } prevCustomHostnames := []string{} newCustomHostnames := map[string]customHostname{} if p.CustomHostnamesConfig.Enabled { if current != nil { prevCustomHostnames = getEndpointCustomHostnames(current) } for _, v := range getEndpointCustomHostnames(ep) { newCustomHostnames[v] = p.newCustomHostname(v, ep.DNSName) } } // Load comment from program flag comment := p.DNSRecordsConfig.Comment if val, ok := ep.GetProviderSpecificProperty(annotations.CloudflareRecordCommentKey); ok { // Replace comment with Ingress annotation comment = val } var tags []string if val, ok := ep.GetProviderSpecificProperty(annotations.CloudflareTagsKey); ok { tags = parseTagsAnnotation(val) } if len(comment) > freeZoneMaxCommentLength { comment = p.DNSRecordsConfig.trimAndValidateComment(ep.DNSName, comment, p.ZoneHasPaidPlan) } var priority float64 if ep.RecordType == "MX" { mxRecord, err := endpoint.NewMXRecord(target) if err != nil { return &cloudFlareChange{}, fmt.Errorf("failed to parse MX record target %q: %w", target, err) } else { priority = float64(*mxRecord.GetPriority()) target = *mxRecord.GetHost() } } return &cloudFlareChange{ Action: action, ResourceRecord: dns.RecordResponse{ Name: ep.DNSName, TTL: ttl, Proxied: proxied, Type: dns.RecordResponseType(ep.RecordType), Content: target, Comment: comment, Tags: tags, Priority: priority, }, RegionalHostname: p.regionalHostname(ep), CustomHostnamesPrev: prevCustomHostnames, CustomHostnames: newCustomHostnames, }, nil } func newDNSRecordIndex(r dns.RecordResponse) DNSRecordIndex { return DNSRecordIndex{Name: r.Name, Type: string(r.Type), Content: r.Content} } // getDNSRecordsMap retrieves all DNS records for a given zone and returns them as a DNSRecordsMap. func (p *CloudFlareProvider) getDNSRecordsMap(ctx context.Context, zoneID string) (DNSRecordsMap, error) { // for faster getRecordID lookup recordsMap := make(DNSRecordsMap) params := dns.RecordListParams{ZoneID: cloudflare.F(zoneID)} if p.DNSRecordsConfig.PerPage > 0 { params.PerPage = cloudflare.F(float64(p.DNSRecordsConfig.PerPage)) } iter := p.Client.ListDNSRecords(ctx, params) for record := range autoPagerIterator(iter) { recordsMap[newDNSRecordIndex(record)] = record } if iter.Err() != nil { return nil, convertCloudflareError(iter.Err()) } return recordsMap, nil } func shouldBeProxied(ep *endpoint.Endpoint, proxiedByDefault bool) bool { proxied := proxiedByDefault for _, v := range ep.ProviderSpecific { if v.Name == annotations.CloudflareProxiedKey { b, err := strconv.ParseBool(v.Value) if err != nil { log.Errorf("Failed to parse annotation [%q]: %v", annotations.CloudflareProxiedKey, err) } else { proxied = b } break } } if recordTypeProxyNotSupported[ep.RecordType] { proxied = false } return proxied } func getEndpointCustomHostnames(ep *endpoint.Endpoint) []string { for _, v := range ep.ProviderSpecific { if v.Name == annotations.CloudflareCustomHostnameKey { customHostnames := strings.Split(v.Value, ",") return customHostnames } } return []string{} } func (p *CloudFlareProvider) groupByNameAndTypeWithCustomHostnames(records DNSRecordsMap, chs customHostnamesMap) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // group supported records by name and type groups := map[string][]dns.RecordResponse{} for _, r := range records { if !p.SupportedAdditionalRecordTypes(string(r.Type)) { continue } groupBy := r.Name + string(r.Type) if _, ok := groups[groupBy]; !ok { groups[groupBy] = []dns.RecordResponse{} } groups[groupBy] = append(groups[groupBy], r) } // map custom origin to custom hostname, custom origin should match to a dns record customHostnames := map[string][]string{} for _, c := range chs { customHostnames[c.customOriginServer] = append(customHostnames[c.customOriginServer], c.hostname) } // create a single endpoint with all the targets for each name/type for _, records := range groups { if len(records) == 0 { return endpoints } targets := make([]string, len(records)) for i, record := range records { if records[i].Type == "MX" { targets[i] = fmt.Sprintf("%v %v", record.Priority, record.Content) } else { targets[i] = record.Content } } e := endpoint.NewEndpointWithTTL( records[0].Name, string(records[0].Type), endpoint.TTL(records[0].TTL), targets...) proxied := records[0].Proxied if e == nil { continue } e = e.WithProviderSpecific(annotations.CloudflareProxiedKey, strconv.FormatBool(proxied)) // noop (customHostnames is empty) if custom hostnames feature is not in use if customHostnames, ok := customHostnames[records[0].Name]; ok { sort.Strings(customHostnames) e = e.WithProviderSpecific(annotations.CloudflareCustomHostnameKey, strings.Join(customHostnames, ",")) } if records[0].Comment != "" { e = e.WithProviderSpecific(annotations.CloudflareRecordCommentKey, records[0].Comment) } if records[0].Tags != nil { if tags, ok := records[0].Tags.([]string); ok && len(tags) > 0 { sort.Strings(tags) e = e.WithProviderSpecific(annotations.CloudflareTagsKey, strings.Join(tags, ",")) } } endpoints = append(endpoints, e) } return endpoints } // SupportedRecordType returns true if the record type is supported by the provider func (p *CloudFlareProvider) SupportedAdditionalRecordTypes(recordType string) bool { switch recordType { case endpoint.RecordTypeMX: return true default: return provider.SupportedRecordType(recordType) } } ================================================ FILE: provider/cloudflare/cloudflare_batch.go ================================================ /* Copyright 2026 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 cloudflare import ( "context" "time" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/dns" log "github.com/sirupsen/logrus" ) const ( // defaultBatchChangeSize is the default maximum number of DNS record // operations included in each Cloudflare batch request. defaultBatchChangeSize = 200 ) // batchCollections groups the parallel slices that are assembled while // classifying per-zone changes. It is passed as a unit to // submitDNSRecordChanges and chunkBatchChanges, replacing the previous // eight-parameter signatures and making it clear which slices travel // together. type batchCollections struct { // Batch API parameters in server-execution order: deletes → puts → posts. batchDeletes []dns.RecordBatchParamsDelete batchPosts []dns.RecordBatchParamsPostUnion batchPuts []dns.BatchPutUnionParam // Parallel change slices — one entry per batch param, in the same order, // so that a failed batch chunk can be replayed with per-record fallback. deleteChanges []*cloudFlareChange createChanges []*cloudFlareChange updateChanges []*cloudFlareChange // fallbackUpdates holds changes for record types whose batch-put param // requires structured Data fields (e.g. SRV, CAA). These are submitted // via individual UpdateDNSRecord calls instead of the batch API. fallbackUpdates []*cloudFlareChange } // batchChunk holds a DNS record batch request alongside the source changes // that produced it, enabling per-record fallback when a batch fails. type batchChunk struct { params dns.RecordBatchParams deleteChanges []*cloudFlareChange createChanges []*cloudFlareChange updateChanges []*cloudFlareChange } // BatchDNSRecords submits a batch of DNS record changes to the Cloudflare API. func (z zoneService) BatchDNSRecords(ctx context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error) { return z.service.DNS.Records.Batch(ctx, params) } // getUpdateDNSRecordParam returns the RecordUpdateParams for an individual update. func getUpdateDNSRecordParam(zoneID string, cfc cloudFlareChange) dns.RecordUpdateParams { return dns.RecordUpdateParams{ ZoneID: cloudflare.F(zoneID), Body: dns.RecordUpdateParamsBody{ Name: cloudflare.F(cfc.ResourceRecord.Name), TTL: cloudflare.F(cfc.ResourceRecord.TTL), Proxied: cloudflare.F(cfc.ResourceRecord.Proxied), Type: cloudflare.F(dns.RecordUpdateParamsBodyType(cfc.ResourceRecord.Type)), Content: cloudflare.F(cfc.ResourceRecord.Content), Priority: cloudflare.F(cfc.ResourceRecord.Priority), Comment: cloudflare.F(cfc.ResourceRecord.Comment), Tags: cloudflare.F(cfc.ResourceRecord.Tags), }, } } // getCreateDNSRecordParam returns the RecordNewParams for an individual create. func getCreateDNSRecordParam(zoneID string, cfc *cloudFlareChange) dns.RecordNewParams { return dns.RecordNewParams{ ZoneID: cloudflare.F(zoneID), Body: dns.RecordNewParamsBody{ Name: cloudflare.F(cfc.ResourceRecord.Name), TTL: cloudflare.F(cfc.ResourceRecord.TTL), Proxied: cloudflare.F(cfc.ResourceRecord.Proxied), Type: cloudflare.F(dns.RecordNewParamsBodyType(cfc.ResourceRecord.Type)), Content: cloudflare.F(cfc.ResourceRecord.Content), Priority: cloudflare.F(cfc.ResourceRecord.Priority), Comment: cloudflare.F(cfc.ResourceRecord.Comment), Tags: cloudflare.F(cfc.ResourceRecord.Tags), }, } } // chunkBatchChanges splits DNS record batch operations into batchChunks, // each containing at most total operations. Operations are distributed // in server-execution order: deletes first, then puts, then posts. // The parallel change slices track which cloudFlareChange produced each batch // param so that individual fallback is possible when a chunk fails. func chunkBatchChanges(zoneID string, bc batchCollections, limit int) []batchChunk { deletes, deleteChanges := bc.batchDeletes, bc.deleteChanges posts, createChanges := bc.batchPosts, bc.createChanges puts, updateChanges := bc.batchPuts, bc.updateChanges var chunks []batchChunk di, pi, ui := 0, 0, 0 for di < len(deletes) || pi < len(posts) || ui < len(puts) { remaining := limit chunk := batchChunk{ params: dns.RecordBatchParams{ZoneID: cloudflare.F(zoneID)}, } if di < len(deletes) && remaining > 0 { end := min(di+remaining, len(deletes)) chunk.params.Deletes = cloudflare.F(deletes[di:end]) chunk.deleteChanges = deleteChanges[di:end] remaining -= end - di di = end } if ui < len(puts) && remaining > 0 { end := min(ui+remaining, len(puts)) chunk.params.Puts = cloudflare.F(puts[ui:end]) chunk.updateChanges = updateChanges[ui:end] remaining -= end - ui ui = end } if pi < len(posts) && remaining > 0 { end := min(pi+remaining, len(posts)) chunk.params.Posts = cloudflare.F(posts[pi:end]) chunk.createChanges = createChanges[pi:end] pi = end } chunks = append(chunks, chunk) } return chunks } // tagsFromResponse converts a RecordResponse Tags field (any) to the typed tag slice. func tagsFromResponse(tags any) []dns.RecordTagsParam { if ts, ok := tags.([]string); ok { return ts } return nil } // buildBatchPostParam constructs a RecordBatchParamsPost for creating a DNS record in a batch. func buildBatchPostParam(r dns.RecordResponse) dns.RecordBatchParamsPost { return dns.RecordBatchParamsPost{ Name: cloudflare.F(r.Name), TTL: cloudflare.F(r.TTL), Type: cloudflare.F(dns.RecordBatchParamsPostsType(r.Type)), Content: cloudflare.F(r.Content), Proxied: cloudflare.F(r.Proxied), Priority: cloudflare.F(r.Priority), Comment: cloudflare.F(r.Comment), Tags: cloudflare.F[any](tagsFromResponse(r.Tags)), } } // buildBatchPutParam constructs a BatchPutUnionParam for updating a DNS record in a batch. // Returns (nil, false) for record types that use structured Data fields (e.g. SRV, CAA), // which fall back to individual UpdateDNSRecord calls. func buildBatchPutParam(id string, r dns.RecordResponse) (dns.BatchPutUnionParam, bool) { tags := tagsFromResponse(r.Tags) comment := r.Comment switch r.Type { case dns.RecordResponseTypeA: return dns.BatchPutARecordParam{ ID: cloudflare.F(id), ARecordParam: dns.ARecordParam{ Name: cloudflare.F(r.Name), TTL: cloudflare.F(r.TTL), Type: cloudflare.F(dns.ARecordTypeA), Content: cloudflare.F(r.Content), Proxied: cloudflare.F(r.Proxied), Comment: cloudflare.F(comment), Tags: cloudflare.F(tags), }, }, true case dns.RecordResponseTypeAAAA: return dns.BatchPutAAAARecordParam{ ID: cloudflare.F(id), AAAARecordParam: dns.AAAARecordParam{ Name: cloudflare.F(r.Name), TTL: cloudflare.F(r.TTL), Type: cloudflare.F(dns.AAAARecordTypeAAAA), Content: cloudflare.F(r.Content), Proxied: cloudflare.F(r.Proxied), Comment: cloudflare.F(comment), Tags: cloudflare.F(tags), }, }, true case dns.RecordResponseTypeCNAME: return dns.BatchPutCNAMERecordParam{ ID: cloudflare.F(id), CNAMERecordParam: dns.CNAMERecordParam{ Name: cloudflare.F(r.Name), TTL: cloudflare.F(r.TTL), Type: cloudflare.F(dns.CNAMERecordTypeCNAME), Content: cloudflare.F(r.Content), Proxied: cloudflare.F(r.Proxied), Comment: cloudflare.F(comment), Tags: cloudflare.F(tags), }, }, true case dns.RecordResponseTypeTXT: return dns.BatchPutTXTRecordParam{ ID: cloudflare.F(id), TXTRecordParam: dns.TXTRecordParam{ Name: cloudflare.F(r.Name), TTL: cloudflare.F(r.TTL), Type: cloudflare.F(dns.TXTRecordTypeTXT), Content: cloudflare.F(r.Content), Proxied: cloudflare.F(r.Proxied), Comment: cloudflare.F(comment), Tags: cloudflare.F(tags), }, }, true case dns.RecordResponseTypeMX: return dns.BatchPutMXRecordParam{ ID: cloudflare.F(id), MXRecordParam: dns.MXRecordParam{ Name: cloudflare.F(r.Name), TTL: cloudflare.F(r.TTL), Type: cloudflare.F(dns.MXRecordTypeMX), Content: cloudflare.F(r.Content), Proxied: cloudflare.F(r.Proxied), Comment: cloudflare.F(comment), Tags: cloudflare.F(tags), Priority: cloudflare.F(r.Priority), }, }, true case dns.RecordResponseTypeNS: return dns.BatchPutNSRecordParam{ ID: cloudflare.F(id), NSRecordParam: dns.NSRecordParam{ Name: cloudflare.F(r.Name), TTL: cloudflare.F(r.TTL), Type: cloudflare.F(dns.NSRecordTypeNS), Content: cloudflare.F(r.Content), Proxied: cloudflare.F(r.Proxied), Comment: cloudflare.F(comment), Tags: cloudflare.F(tags), }, }, true default: // Record types that use structured Data fields (SRV, CAA, etc.) are not // supported in the generic batch put and fall back to individual updates. return nil, false } } // buildBatchCollections classifies per-zone changes into batch collections. // Custom hostname side-effects are handled separately by // processCustomHostnameChanges before this is called. func (p *CloudFlareProvider) buildBatchCollections( zoneID string, changes []*cloudFlareChange, records DNSRecordsMap, ) batchCollections { var bc batchCollections for _, change := range changes { logFields := log.Fields{ "record": change.ResourceRecord.Name, "type": change.ResourceRecord.Type, "ttl": change.ResourceRecord.TTL, "action": change.Action.String(), "zone": zoneID, } switch change.Action { case cloudFlareCreate: bc.batchPosts = append(bc.batchPosts, buildBatchPostParam(change.ResourceRecord)) bc.createChanges = append(bc.createChanges, change) case cloudFlareDelete: recordID := p.getRecordID(records, change.ResourceRecord) if recordID == "" { log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord) continue } bc.batchDeletes = append(bc.batchDeletes, dns.RecordBatchParamsDelete{ID: cloudflare.F(recordID)}) bc.deleteChanges = append(bc.deleteChanges, change) case cloudFlareUpdate: recordID := p.getRecordID(records, change.ResourceRecord) if recordID == "" { log.WithFields(logFields).Errorf("failed to find previous record: %v", change.ResourceRecord) continue } if putParam, ok := buildBatchPutParam(recordID, change.ResourceRecord); ok { bc.batchPuts = append(bc.batchPuts, putParam) bc.updateChanges = append(bc.updateChanges, change) } else { log.WithFields(logFields).Debugf("batch PUT not supported for type %s, using individual update", change.ResourceRecord.Type) bc.fallbackUpdates = append(bc.fallbackUpdates, change) } } } return bc } // submitDNSRecordChanges submits the pre-built batch collections and any // fallback individual updates for a single zone. When a batch chunk fails, // the provider falls back to individual API calls for that chunk's changes // (since the batch is transactional — failure means full rollback). // Returns true if any operation fails. func (p *CloudFlareProvider) submitDNSRecordChanges( ctx context.Context, zoneID string, bc batchCollections, records DNSRecordsMap, ) bool { failed := false if len(bc.batchDeletes) > 0 || len(bc.batchPosts) > 0 || len(bc.batchPuts) > 0 { limit := max(p.DNSRecordsConfig.BatchChangeSize, defaultBatchChangeSize) chunks := chunkBatchChanges(zoneID, bc, limit) for i, chunk := range chunks { log.Debugf("Submitting batch DNS records for zone %s (chunk %d/%d): %d deletes, %d creates, %d updates", zoneID, i+1, len(chunks), len(chunk.params.Deletes.Value), len(chunk.params.Posts.Value), len(chunk.params.Puts.Value), ) if _, err := p.Client.BatchDNSRecords(ctx, chunk.params); err != nil { log.Warnf("Batch DNS operation failed for zone %s (chunk %d/%d): %v — falling back to individual operations", zoneID, i+1, len(chunks), convertCloudflareError(err)) if p.fallbackIndividualChanges(ctx, zoneID, chunk, records) { failed = true } } else { log.Debugf("Successfully submitted batch DNS records for zone %s (chunk %d/%d)", zoneID, i+1, len(chunks)) } if i < len(chunks)-1 && p.DNSRecordsConfig.BatchChangeInterval > 0 { time.Sleep(p.DNSRecordsConfig.BatchChangeInterval) } } } for _, change := range bc.fallbackUpdates { logFields := log.Fields{ "record": change.ResourceRecord.Name, "type": change.ResourceRecord.Type, "ttl": change.ResourceRecord.TTL, "action": change.Action.String(), "zone": zoneID, } recordID := p.getRecordID(records, change.ResourceRecord) recordParam := getUpdateDNSRecordParam(zoneID, *change) if _, err := p.Client.UpdateDNSRecord(ctx, recordID, recordParam); err != nil { failed = true log.WithFields(logFields).Errorf("failed to update record: %v", err) } else { log.WithFields(logFields).Debugf("individual update succeeded") } } return failed } // fallbackIndividualChanges replays a failed (rolled-back) batch chunk as // individual API calls. Because the batch API is transactional, a failure means // zero state was changed in that chunk, so these individual calls are the first // real mutations. Individual calls return Cloudflare's own per-record error // details. // // Execution order matches the batch contract: deletes → updates → creates. // Returns true if any operation failed. func (p *CloudFlareProvider) fallbackIndividualChanges( ctx context.Context, zoneID string, chunk batchChunk, records DNSRecordsMap, ) bool { failed := false // Process in batch execution order: deletes → updates → creates. groups := []struct { changes []*cloudFlareChange }{ {chunk.deleteChanges}, {chunk.updateChanges}, {chunk.createChanges}, } for _, group := range groups { for _, change := range group.changes { logFields := log.Fields{ "record": change.ResourceRecord.Name, "type": change.ResourceRecord.Type, "content": change.ResourceRecord.Content, "action": change.Action.String(), "zone": zoneID, } var err error switch change.Action { case cloudFlareCreate: params := getCreateDNSRecordParam(zoneID, change) _, err = p.Client.CreateDNSRecord(ctx, params) case cloudFlareDelete: recordID := p.getRecordID(records, change.ResourceRecord) if recordID == "" { // Record is already absent — the desired state is achieved. log.WithFields(logFields).Info("fallback: record already gone, treating delete as success") continue } err = p.Client.DeleteDNSRecord(ctx, recordID, dns.RecordDeleteParams{ ZoneID: cloudflare.F(zoneID), }) case cloudFlareUpdate: recordID := p.getRecordID(records, change.ResourceRecord) if recordID == "" { // Record is gone; let the next sync cycle issue a fresh CREATE. log.WithFields(logFields).Info("fallback: record unexpectedly not found for update, will re-evaluate on next sync") continue } params := getUpdateDNSRecordParam(zoneID, *change) _, err = p.Client.UpdateDNSRecord(ctx, recordID, params) } if err != nil { failed = true log.WithFields(logFields).Errorf("fallback: individual %s failed: %v", change.Action, convertCloudflareError(err)) } else { log.WithFields(logFields).Debugf("fallback: individual %s succeeded", change.Action) } } } return failed } ================================================ FILE: provider/cloudflare/cloudflare_batch_test.go ================================================ /* Copyright 2026 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 cloudflare import ( "context" "errors" "fmt" "maps" "strings" "testing" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) func (m *mockCloudFlareClient) BatchDNSRecords(_ context.Context, params dns.RecordBatchParams) (*dns.RecordBatchResponse, error) { m.BatchDNSRecordsCalls++ zoneID := params.ZoneID.Value // Snapshot zone state for transactional rollback on error. // The real Cloudflare batch API is fully transactional — if any // operation fails, the entire batch is rolled back. var snapshot map[string]dns.RecordResponse if zone, ok := m.Records[zoneID]; ok { snapshot = make(map[string]dns.RecordResponse, len(zone)) maps.Copy(snapshot, zone) } actionsStart := len(m.Actions) var firstErr error // Process Deletes first to mirror the real API's ordering. for _, del := range params.Deletes.Value { recordID := del.ID.Value m.Actions = append(m.Actions, MockAction{ Name: "Delete", ZoneId: zoneID, RecordId: recordID, }) if zone, ok := m.Records[zoneID]; ok { if rec, exists := zone[recordID]; exists { name := rec.Name delete(zone, recordID) if strings.HasPrefix(name, "newerror-delete-") && firstErr == nil { firstErr = errors.New("failed to delete erroring DNS record") } } } } // Process Puts (updates) before Posts (creates) to mirror the real API's // server-side execution order: Deletes → Patches → Puts → Posts. for _, putUnion := range params.Puts.Value { id, record := extractBatchPutData(putUnion) m.Actions = append(m.Actions, MockAction{ Name: "Update", ZoneId: zoneID, RecordId: id, RecordData: record, }) if zone, ok := m.Records[zoneID]; ok { if _, exists := zone[id]; exists { if strings.HasPrefix(record.Name, "newerror-update-") { if firstErr == nil { firstErr = errors.New("failed to update erroring DNS record") } } else { zone[id] = record } } } } // Process Posts (creates). for _, postUnion := range params.Posts.Value { post, ok := postUnion.(dns.RecordBatchParamsPost) if !ok { continue } typeStr := string(post.Type.Value) record := dns.RecordResponse{ ID: generateDNSRecordID(typeStr, post.Name.Value, post.Content.Value), Name: post.Name.Value, TTL: dns.TTL(post.TTL.Value), Proxied: post.Proxied.Value, Type: dns.RecordResponseType(typeStr), Content: post.Content.Value, Priority: post.Priority.Value, } m.Actions = append(m.Actions, MockAction{ Name: "Create", ZoneId: zoneID, RecordId: record.ID, RecordData: record, }) if zone, ok := m.Records[zoneID]; ok { zone[record.ID] = record } if record.Name == "newerror.bar.com" && firstErr == nil { firstErr = fmt.Errorf("failed to create record") } } // Transactional: on error, rollback all state and action changes. if firstErr != nil { if snapshot != nil { m.Records[zoneID] = snapshot } m.Actions = m.Actions[:actionsStart] return nil, firstErr } return &dns.RecordBatchResponse{}, nil } // extractBatchPutData unpacks a BatchPutUnionParam into a record ID and a RecordResponse // suitable for recording in the mock's Actions list. func extractBatchPutData(put dns.BatchPutUnionParam) (string, dns.RecordResponse) { switch p := put.(type) { case dns.BatchPutARecordParam: return p.ID.Value, dns.RecordResponse{ ID: p.ID.Value, Name: p.Name.Value, TTL: p.TTL.Value, Proxied: p.Proxied.Value, Type: dns.RecordResponseTypeA, Content: p.Content.Value, } case dns.BatchPutAAAARecordParam: return p.ID.Value, dns.RecordResponse{ ID: p.ID.Value, Name: p.Name.Value, TTL: p.TTL.Value, Proxied: p.Proxied.Value, Type: dns.RecordResponseTypeAAAA, Content: p.Content.Value, } case dns.BatchPutCNAMERecordParam: return p.ID.Value, dns.RecordResponse{ ID: p.ID.Value, Name: p.Name.Value, TTL: p.TTL.Value, Proxied: p.Proxied.Value, Type: dns.RecordResponseTypeCNAME, Content: p.Content.Value, } case dns.BatchPutTXTRecordParam: return p.ID.Value, dns.RecordResponse{ ID: p.ID.Value, Name: p.Name.Value, TTL: p.TTL.Value, Proxied: p.Proxied.Value, Type: dns.RecordResponseTypeTXT, Content: p.Content.Value, } case dns.BatchPutMXRecordParam: return p.ID.Value, dns.RecordResponse{ ID: p.ID.Value, Name: p.Name.Value, TTL: p.TTL.Value, Proxied: p.Proxied.Value, Type: dns.RecordResponseTypeMX, Content: p.Content.Value, Priority: p.Priority.Value, } case dns.BatchPutNSRecordParam: return p.ID.Value, dns.RecordResponse{ ID: p.ID.Value, Name: p.Name.Value, TTL: p.TTL.Value, Proxied: p.Proxied.Value, Type: dns.RecordResponseTypeNS, Content: p.Content.Value, } default: panic(fmt.Sprintf("extractBatchPutData: unexpected BatchPutUnionParam type %T", put)) } } // generateDNSRecordID builds the deterministic record ID used by the mock client. func generateDNSRecordID(rrtype string, name string, content string) string { return fmt.Sprintf("%s-%s-%s", name, rrtype, content) } func TestBatchFallbackIndividual(t *testing.T) { t.Run("batch failure falls back to individual operations", func(t *testing.T) { // Create a provider with pre-existing records. client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { {ID: "existing-1", Name: "ok.bar.com", Type: "A", Content: "1.2.3.4", TTL: 120}, }, }) p := &CloudFlareProvider{ Client: client, } // Apply changes that include a good create and a bad create. // "newerror.bar.com" triggers a batch failure in the mock BatchDNSRecords, // then an individual fallback failure in CreateDNSRecord. changes := &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "good.bar.com", Targets: endpoint.Targets{"5.6.7.8"}, RecordType: "A"}, {DNSName: "newerror.bar.com", Targets: endpoint.Targets{"9.10.11.12"}, RecordType: "A"}, }, } err := p.ApplyChanges(t.Context(), changes) require.Error(t, err, "should return error when individual fallback has failures") assert.Equal(t, 1, client.BatchDNSRecordsCalls, "batch path should be attempted before fallback") // The batch should have failed (because of newerror.bar.com), then // fallback should have applied "good.bar.com" individually (success) // and "newerror.bar.com" individually (failure). // Verify the good record was created via individual fallback. zone001 := client.Records["001"] goodID := generateDNSRecordID("A", "good.bar.com", "5.6.7.8") assert.Contains(t, zone001, goodID, "good record should exist after individual fallback") }) t.Run("failed individual delete is reported", func(t *testing.T) { // When a batch containing two deletes fails, the fallback replays them // individually. The one that ultimately fails should be reported; // the one that succeeds should not block the overall zone from converging. client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { {ID: "del-ok", Name: "deleteme.bar.com", Type: "A", Content: "1.2.3.4", TTL: 120}, {ID: "del-err", Name: "newerror-delete-1.bar.com", Type: "A", Content: "5.6.7.8", TTL: 120}, }, }) p := &CloudFlareProvider{ Client: client, } changes := &plan.Changes{ Delete: []*endpoint.Endpoint{ {DNSName: "deleteme.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"}, {DNSName: "newerror-delete-1.bar.com", Targets: endpoint.Targets{"5.6.7.8"}, RecordType: "A"}, }, } err := p.ApplyChanges(t.Context(), changes) require.Error(t, err, "should return error for the failing delete") // The good delete should have succeeded via individual fallback. assert.NotContains(t, client.Records["001"], "del-ok", "successfully deleted record should be gone") }) t.Run("fallback update failure is reported", func(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { {ID: "upd-err", Name: "newerror-update-1.bar.com", Type: "A", Content: "1.2.3.4", TTL: 120}, }, }) p := &CloudFlareProvider{ Client: client, } changes := &plan.Changes{ UpdateNew: []*endpoint.Endpoint{ {DNSName: "newerror-update-1.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A", RecordTTL: 300}, }, UpdateOld: []*endpoint.Endpoint{ {DNSName: "newerror-update-1.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A", RecordTTL: 120}, }, } err := p.ApplyChanges(t.Context(), changes) require.Error(t, err, "should return error for the failing update") }) } func TestChunkBatchChanges(t *testing.T) { // Build sample changes and batch params. mkDelete := func(id string) dns.RecordBatchParamsDelete { return dns.RecordBatchParamsDelete{ID: cloudflare.F(id)} } mkPost := func(name, content string) dns.RecordBatchParamsPostUnion { return dns.RecordBatchParamsPost{ Name: cloudflare.F(name), Type: cloudflare.F(dns.RecordBatchParamsPostsTypeA), Content: cloudflare.F(content), } } mkPut := func(id, name, content string) dns.BatchPutUnionParam { return dns.BatchPutARecordParam{ ID: cloudflare.F(id), ARecordParam: dns.ARecordParam{ Name: cloudflare.F(name), Type: cloudflare.F(dns.ARecordTypeA), Content: cloudflare.F(content), }, } } mkChange := func(action changeAction, name, content string) *cloudFlareChange { return &cloudFlareChange{ Action: action, ResourceRecord: dns.RecordResponse{Name: name, Type: "A", Content: content}, } } deletes := []dns.RecordBatchParamsDelete{mkDelete("d1"), mkDelete("d2")} deleteChanges := []*cloudFlareChange{ mkChange(cloudFlareDelete, "del1.bar.com", "1.1.1.1"), mkChange(cloudFlareDelete, "del2.bar.com", "2.2.2.2"), } posts := []dns.RecordBatchParamsPostUnion{mkPost("create1.bar.com", "3.3.3.3")} createChanges := []*cloudFlareChange{ mkChange(cloudFlareCreate, "create1.bar.com", "3.3.3.3"), } puts := []dns.BatchPutUnionParam{mkPut("u1", "update1.bar.com", "4.4.4.4")} updateChanges := []*cloudFlareChange{ mkChange(cloudFlareUpdate, "update1.bar.com", "4.4.4.4"), } t.Run("single chunk when under limit", func(t *testing.T) { bc := batchCollections{ batchDeletes: deletes, deleteChanges: deleteChanges, batchPosts: posts, createChanges: createChanges, batchPuts: puts, updateChanges: updateChanges, } chunks := chunkBatchChanges("zone1", bc, 10) require.Len(t, chunks, 1) assert.Len(t, chunks[0].deleteChanges, 2) assert.Len(t, chunks[0].createChanges, 1) assert.Len(t, chunks[0].updateChanges, 1) }) t.Run("splits into multiple chunks at limit", func(t *testing.T) { bc := batchCollections{ batchDeletes: deletes, deleteChanges: deleteChanges, batchPosts: posts, createChanges: createChanges, batchPuts: puts, updateChanges: updateChanges, } chunks := chunkBatchChanges("zone1", bc, 2) require.Len(t, chunks, 2) // First chunk: 2 deletes (fills limit) assert.Len(t, chunks[0].deleteChanges, 2) assert.Empty(t, chunks[0].updateChanges) assert.Empty(t, chunks[0].createChanges) // Second chunk: 1 put then 1 post assert.Empty(t, chunks[1].deleteChanges) assert.Len(t, chunks[1].updateChanges, 1) assert.Len(t, chunks[1].createChanges, 1) }) t.Run("preserves operation order across chunk boundaries", func(t *testing.T) { bc := batchCollections{ batchDeletes: []dns.RecordBatchParamsDelete{mkDelete("d1")}, deleteChanges: []*cloudFlareChange{ mkChange(cloudFlareDelete, "del1.bar.com", "1.1.1.1"), }, batchPuts: []dns.BatchPutUnionParam{ mkPut("u1", "update1.bar.com", "2.2.2.2"), mkPut("u2", "update2.bar.com", "3.3.3.3"), }, updateChanges: []*cloudFlareChange{ mkChange(cloudFlareUpdate, "update1.bar.com", "2.2.2.2"), mkChange(cloudFlareUpdate, "update2.bar.com", "3.3.3.3"), }, batchPosts: []dns.RecordBatchParamsPostUnion{ mkPost("create1.bar.com", "4.4.4.4"), mkPost("create2.bar.com", "5.5.5.5"), }, createChanges: []*cloudFlareChange{ mkChange(cloudFlareCreate, "create1.bar.com", "4.4.4.4"), mkChange(cloudFlareCreate, "create2.bar.com", "5.5.5.5"), }, } chunks := chunkBatchChanges("zone1", bc, 2) require.Len(t, chunks, 3) assert.Len(t, chunks[0].deleteChanges, 1) assert.Len(t, chunks[0].updateChanges, 1) assert.Empty(t, chunks[0].createChanges) assert.Empty(t, chunks[1].deleteChanges) assert.Len(t, chunks[1].updateChanges, 1) assert.Len(t, chunks[1].createChanges, 1) assert.Empty(t, chunks[2].deleteChanges) assert.Empty(t, chunks[2].updateChanges) assert.Len(t, chunks[2].createChanges, 1) }) } func TestTagsFromResponse(t *testing.T) { t.Run("nil input returns nil", func(t *testing.T) { assert.Nil(t, tagsFromResponse(nil)) }) t.Run("non-string-slice returns nil", func(t *testing.T) { assert.Nil(t, tagsFromResponse(42)) }) t.Run("string slice is returned unchanged", func(t *testing.T) { tags := []string{"tag1", "tag2"} assert.Equal(t, tags, tagsFromResponse(tags)) }) } func TestBuildBatchPutParam(t *testing.T) { base := dns.RecordResponse{ Name: "example.bar.com", TTL: 120, Proxied: false, Comment: "test-comment", } t.Run("AAAA record", func(t *testing.T) { r := base r.Type = dns.RecordResponseTypeAAAA r.Content = "2001:db8::1" param, ok := buildBatchPutParam("id-aaaa", r) require.True(t, ok) p, cast := param.(dns.BatchPutAAAARecordParam) require.True(t, cast) assert.Equal(t, "id-aaaa", p.ID.Value) assert.Equal(t, "2001:db8::1", p.Content.Value) assert.Equal(t, dns.AAAARecordTypeAAAA, p.Type.Value) }) t.Run("CNAME record", func(t *testing.T) { r := base r.Type = dns.RecordResponseTypeCNAME r.Content = "target.bar.com" param, ok := buildBatchPutParam("id-cname", r) require.True(t, ok) p, cast := param.(dns.BatchPutCNAMERecordParam) require.True(t, cast) assert.Equal(t, "id-cname", p.ID.Value) assert.Equal(t, "target.bar.com", p.Content.Value) assert.Equal(t, dns.CNAMERecordTypeCNAME, p.Type.Value) }) t.Run("TXT record", func(t *testing.T) { r := base r.Type = dns.RecordResponseTypeTXT r.Content = "v=spf1 include:example.com ~all" param, ok := buildBatchPutParam("id-txt", r) require.True(t, ok) p, cast := param.(dns.BatchPutTXTRecordParam) require.True(t, cast) assert.Equal(t, "id-txt", p.ID.Value) assert.Equal(t, dns.TXTRecordTypeTXT, p.Type.Value) }) t.Run("MX record with priority", func(t *testing.T) { r := base r.Type = dns.RecordResponseTypeMX r.Content = "mail.example.com" r.Priority = 10 param, ok := buildBatchPutParam("id-mx", r) require.True(t, ok) p, cast := param.(dns.BatchPutMXRecordParam) require.True(t, cast) assert.Equal(t, "id-mx", p.ID.Value) assert.InDelta(t, float64(10), float64(p.Priority.Value), 0) assert.Equal(t, dns.MXRecordTypeMX, p.Type.Value) }) t.Run("NS record", func(t *testing.T) { r := base r.Type = dns.RecordResponseTypeNS r.Content = "ns1.example.com" param, ok := buildBatchPutParam("id-ns", r) require.True(t, ok) p, cast := param.(dns.BatchPutNSRecordParam) require.True(t, cast) assert.Equal(t, "id-ns", p.ID.Value) assert.Equal(t, dns.NSRecordTypeNS, p.Type.Value) }) t.Run("SRV record falls back (returns nil, false)", func(t *testing.T) { r := base r.Type = dns.RecordResponseTypeSRV r.Content = "10 20 443 target.bar.com" param, ok := buildBatchPutParam("id-srv", r) assert.False(t, ok) assert.Nil(t, param) }) t.Run("CAA record falls back (returns nil, false)", func(t *testing.T) { r := base r.Type = dns.RecordResponseTypeCAA r.Content = "0 issue letsencrypt.org" param, ok := buildBatchPutParam("id-caa", r) assert.False(t, ok) assert.Nil(t, param) }) } func TestBuildBatchCollections_EdgeCases(t *testing.T) { p := &CloudFlareProvider{} t.Run("update with missing record ID is skipped", func(t *testing.T) { changes := []*cloudFlareChange{ { Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{ Name: "missing.bar.com", Type: dns.RecordResponseTypeA, Content: "1.2.3.4", }, }, } // Empty records map — getRecordID will return "" bc := p.buildBatchCollections("zone1", changes, make(DNSRecordsMap)) assert.Empty(t, bc.batchPuts, "missing record should not be added to batch puts") assert.Empty(t, bc.updateChanges) assert.Empty(t, bc.fallbackUpdates) }) t.Run("SRV update goes to fallbackUpdates", func(t *testing.T) { srvRecord := dns.RecordResponse{ ID: "srv-1", Name: "srv.bar.com", Type: dns.RecordResponseTypeSRV, Content: "10 20 443 target.bar.com", } records := DNSRecordsMap{ newDNSRecordIndex(srvRecord): srvRecord, } changes := []*cloudFlareChange{ { Action: cloudFlareUpdate, ResourceRecord: srvRecord, }, } bc := p.buildBatchCollections("zone1", changes, records) assert.Empty(t, bc.batchPuts, "SRV should not be in batch puts") assert.Empty(t, bc.updateChanges) require.Len(t, bc.fallbackUpdates, 1) assert.Equal(t, "srv.bar.com", bc.fallbackUpdates[0].ResourceRecord.Name) }) t.Run("delete with missing record ID is skipped", func(t *testing.T) { changes := []*cloudFlareChange{ { Action: cloudFlareDelete, ResourceRecord: dns.RecordResponse{ Name: "gone.bar.com", Type: dns.RecordResponseTypeA, Content: "1.2.3.4", }, }, } bc := p.buildBatchCollections("zone1", changes, make(DNSRecordsMap)) assert.Empty(t, bc.batchDeletes, "missing record should not be added to batch deletes") assert.Empty(t, bc.deleteChanges) }) } func TestSubmitDNSRecordChanges_BatchInterval(t *testing.T) { // Build 201 creates so they span 2 chunks (defaultBatchChangeSize=200), // triggering the time.Sleep(BatchChangeInterval) code path between chunks. client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": {}, }) p := &CloudFlareProvider{ Client: client, DNSRecordsConfig: DNSRecordsConfig{ BatchChangeInterval: 1, // 1 nanosecond — non-zero triggers sleep }, } const nRecords = defaultBatchChangeSize + 1 var posts []dns.RecordBatchParamsPostUnion var createChanges []*cloudFlareChange for i := range nRecords { name := fmt.Sprintf("record%d.bar.com", i) posts = append(posts, dns.RecordBatchParamsPost{ Name: cloudflare.F(name), Type: cloudflare.F(dns.RecordBatchParamsPostsTypeA), Content: cloudflare.F("1.2.3.4"), }) createChanges = append(createChanges, &cloudFlareChange{ Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{Name: name, Type: "A", Content: "1.2.3.4"}, }) } bc := batchCollections{ batchPosts: posts, createChanges: createChanges, } failed := p.submitDNSRecordChanges(t.Context(), "001", bc, make(DNSRecordsMap)) assert.False(t, failed, "should not fail") assert.Equal(t, 2, client.BatchDNSRecordsCalls, "two chunks should require two batch API calls") } func TestSubmitDNSRecordChanges_FallbackUpdates(t *testing.T) { t.Run("successful SRV fallback update", func(t *testing.T) { srvRecord := dns.RecordResponse{ ID: "srv-1", Name: "srv.bar.com", Type: dns.RecordResponseTypeSRV, Content: "10 20 443 target.bar.com", } client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": {srvRecord}, }) p := &CloudFlareProvider{Client: client} records := DNSRecordsMap{ newDNSRecordIndex(srvRecord): srvRecord, } bc := batchCollections{ fallbackUpdates: []*cloudFlareChange{ {Action: cloudFlareUpdate, ResourceRecord: srvRecord}, }, } failed := p.submitDNSRecordChanges(t.Context(), "001", bc, records) assert.False(t, failed, "successful SRV fallback update should not report failure") assert.Equal(t, 0, client.BatchDNSRecordsCalls, "batch API not called for fallback-only changes") }) t.Run("failed SRV fallback update is reported", func(t *testing.T) { srvRecord := dns.RecordResponse{ ID: "newerror-upd-srv", Name: "newerror-update-srv.bar.com", Type: dns.RecordResponseTypeSRV, Content: "10 20 443 target.bar.com", } client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": {srvRecord}, }) p := &CloudFlareProvider{Client: client} records := DNSRecordsMap{ newDNSRecordIndex(srvRecord): srvRecord, } bc := batchCollections{ fallbackUpdates: []*cloudFlareChange{ {Action: cloudFlareUpdate, ResourceRecord: srvRecord}, }, } failed := p.submitDNSRecordChanges(t.Context(), "001", bc, records) assert.True(t, failed, "failed SRV fallback update should be reported") }) } func TestFallbackIndividualChanges_MissingRecord(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": {}, }) p := &CloudFlareProvider{Client: client} emptyRecords := make(DNSRecordsMap) t.Run("delete where record is already gone succeeds silently", func(t *testing.T) { chunk := batchChunk{ deleteChanges: []*cloudFlareChange{ { Action: cloudFlareDelete, ResourceRecord: dns.RecordResponse{ Name: "gone.bar.com", Type: dns.RecordResponseTypeA, Content: "1.2.3.4", }, }, }, } failed := p.fallbackIndividualChanges(t.Context(), "001", chunk, emptyRecords) assert.False(t, failed, "delete of already-absent record should not report failure") }) t.Run("update where record is not found skips gracefully", func(t *testing.T) { chunk := batchChunk{ updateChanges: []*cloudFlareChange{ { Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{ Name: "missing.bar.com", Type: dns.RecordResponseTypeA, Content: "1.2.3.4", }, }, }, } failed := p.fallbackIndividualChanges(t.Context(), "001", chunk, emptyRecords) assert.False(t, failed, "update of missing record should not report failure") }) } ================================================ FILE: provider/cloudflare/cloudflare_custom_hostnames.go ================================================ /* Copyright 2026 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 cloudflare import ( "context" "errors" "fmt" "maps" "slices" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/custom_hostnames" "github.com/cloudflare/cloudflare-go/v5/option" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/provider" ) // customHostname represents a Cloudflare custom hostname (v5 API compatible wrapper) type customHostname struct { id string hostname string customOriginServer string customOriginSNI string ssl *customHostnameSSL } // customHostnameSSL represents SSL configuration for custom hostname type customHostnameSSL struct { sslType string method string bundleMethod string certificateAuthority string settings customHostnameSSLSettings } // customHostnameSSLSettings represents SSL settings for custom hostname type customHostnameSSLSettings struct { minTLSVersion string } // for faster getCustomHostname() lookup type customHostnameIndex struct { hostname string } type customHostnamesMap map[customHostnameIndex]customHostname type CustomHostnamesConfig struct { Enabled bool MinTLSVersion string CertificateAuthority string } var recordTypeCustomHostnameSupported = map[string]bool{ "A": true, "CNAME": true, } func (z zoneService) CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] { params := custom_hostnames.CustomHostnameListParams{ ZoneID: cloudflare.F(zoneID), } return z.service.CustomHostnames.ListAutoPaging(ctx, params) } func (z zoneService) DeleteCustomHostname(ctx context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error { _, err := z.service.CustomHostnames.Delete(ctx, customHostnameID, params) return err } func (z zoneService) CreateCustomHostname(ctx context.Context, zoneID string, ch customHostname) error { params := buildCustomHostnameNewParams(zoneID, ch) _, err := z.service.CustomHostnames.New(ctx, params, option.WithJSONSet("custom_origin_server", ch.customOriginServer)) return err } // buildCustomHostnameNewParams builds the params for creating a custom hostname func buildCustomHostnameNewParams(zoneID string, ch customHostname) custom_hostnames.CustomHostnameNewParams { params := custom_hostnames.CustomHostnameNewParams{ ZoneID: cloudflare.F(zoneID), Hostname: cloudflare.F(ch.hostname), } if ch.ssl != nil { sslParams := custom_hostnames.CustomHostnameNewParamsSSL{} if ch.ssl.method != "" { sslParams.Method = cloudflare.F(custom_hostnames.DCVMethod(ch.ssl.method)) } if ch.ssl.sslType != "" { sslParams.Type = cloudflare.F(custom_hostnames.DomainValidationType(ch.ssl.sslType)) } if ch.ssl.bundleMethod != "" { sslParams.BundleMethod = cloudflare.F(custom_hostnames.BundleMethod(ch.ssl.bundleMethod)) } if ch.ssl.certificateAuthority != "" && ch.ssl.certificateAuthority != "none" { sslParams.CertificateAuthority = cloudflare.F(cloudflare.CertificateCA(ch.ssl.certificateAuthority)) } if ch.ssl.settings.minTLSVersion != "" { sslParams.Settings = cloudflare.F(custom_hostnames.CustomHostnameNewParamsSSLSettings{ MinTLSVersion: cloudflare.F(custom_hostnames.CustomHostnameNewParamsSSLSettingsMinTLSVersion(ch.ssl.settings.minTLSVersion)), }) } params.SSL = cloudflare.F(sslParams) } return params } // submitCustomHostnameChanges implements Custom Hostname functionality for the Change, returns false if it fails func (p *CloudFlareProvider) submitCustomHostnameChanges(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool { // return early if disabled if !p.CustomHostnamesConfig.Enabled { return true } switch change.Action { case cloudFlareUpdate: return p.processCustomHostnameUpdate(ctx, zoneID, change, chs, logFields) case cloudFlareDelete: return p.processCustomHostnameDelete(ctx, zoneID, change, chs, logFields) case cloudFlareCreate: return p.processCustomHostnameCreate(ctx, zoneID, change, chs, logFields) } return true } func (p *CloudFlareProvider) processCustomHostnameUpdate(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool { if !recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] { return true } failedChange := false add, remove, _ := provider.Difference(change.CustomHostnamesPrev, slices.Collect(maps.Keys(change.CustomHostnames))) for _, changeCH := range remove { if prevCh, err := getCustomHostname(chs, changeCH); err == nil { prevChID := prevCh.id if prevChID != "" { log.WithFields(logFields).Infof("Removing previous custom hostname %q/%q", prevChID, changeCH) params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)} chErr := p.Client.DeleteCustomHostname(ctx, prevChID, params) if chErr != nil { failedChange = true log.WithFields(logFields).Errorf("failed to remove previous custom hostname %q/%q: %v", prevChID, changeCH, chErr) } } } } for _, changeCH := range add { log.WithFields(logFields).Infof("Adding custom hostname %q", changeCH) chErr := p.Client.CreateCustomHostname(ctx, zoneID, change.CustomHostnames[changeCH]) if chErr != nil { failedChange = true log.WithFields(logFields).Errorf("failed to add custom hostname %q: %v", changeCH, chErr) } } return !failedChange } func (p *CloudFlareProvider) processCustomHostnameDelete(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool { failedChange := false for _, changeCH := range change.CustomHostnames { if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.hostname != "" { log.WithFields(logFields).Infof("Deleting custom hostname %q", changeCH.hostname) if ch, err := getCustomHostname(chs, changeCH.hostname); err == nil { chID := ch.id params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)} chErr := p.Client.DeleteCustomHostname(ctx, chID, params) if chErr != nil { failedChange = true log.WithFields(logFields).Errorf("failed to delete custom hostname %q/%q: %v", chID, changeCH.hostname, chErr) } } else { log.WithFields(logFields).Warnf("failed to delete custom hostname %q: %v", changeCH.hostname, err) } } } return !failedChange } func (p *CloudFlareProvider) processCustomHostnameCreate(ctx context.Context, zoneID string, change *cloudFlareChange, chs customHostnamesMap, logFields log.Fields) bool { failedChange := false for _, changeCH := range change.CustomHostnames { if recordTypeCustomHostnameSupported[string(change.ResourceRecord.Type)] && changeCH.hostname != "" { log.WithFields(logFields).Infof("Creating custom hostname %q", changeCH.hostname) if ch, err := getCustomHostname(chs, changeCH.hostname); err == nil { if changeCH.customOriginServer == ch.customOriginServer { log.WithFields(logFields).Warnf("custom hostname %q already exists with the same origin %q, continue", changeCH.hostname, ch.customOriginServer) } else { failedChange = true log.WithFields(logFields).Errorf("failed to create custom hostname, %q already exists with origin %q", changeCH.hostname, ch.customOriginServer) } } else { chErr := p.Client.CreateCustomHostname(ctx, zoneID, changeCH) if chErr != nil { failedChange = true log.WithFields(logFields).Errorf("failed to create custom hostname %q: %v", changeCH.hostname, chErr) } } } } return !failedChange } func getCustomHostname(chs customHostnamesMap, chName string) (customHostname, error) { if chName == "" { return customHostname{}, fmt.Errorf("failed to get custom hostname: %q is empty", chName) } if ch, ok := chs[customHostnameIndex{hostname: chName}]; ok { return ch, nil } return customHostname{}, fmt.Errorf("failed to get custom hostname: %q not found", chName) } func (p *CloudFlareProvider) newCustomHostname(hostname string, origin string) customHostname { return customHostname{ hostname: hostname, customOriginServer: origin, ssl: getCustomHostnamesSSLOptions(p.CustomHostnamesConfig), } } func getCustomHostnamesSSLOptions(customHostnamesConfig CustomHostnamesConfig) *customHostnameSSL { ssl := &customHostnameSSL{ sslType: "dv", method: "http", bundleMethod: "ubiquitous", settings: customHostnameSSLSettings{ minTLSVersion: customHostnamesConfig.MinTLSVersion, }, } // Set CertificateAuthority if provided // We're not able to set it at all (even with a blank) if you're not on an enterprise plan if customHostnamesConfig.CertificateAuthority != "none" { ssl.certificateAuthority = customHostnamesConfig.CertificateAuthority } return ssl } func newCustomHostnameIndex(ch customHostname) customHostnameIndex { return customHostnameIndex{hostname: ch.hostname} } // listCustomHostnamesWithPagination performs automatic pagination of results on requests to cloudflare.CustomHostnames func (p *CloudFlareProvider) listCustomHostnamesWithPagination(ctx context.Context, zoneID string) (customHostnamesMap, error) { if !p.CustomHostnamesConfig.Enabled { return nil, nil } chs := make(customHostnamesMap) iter := p.Client.CustomHostnames(ctx, zoneID) customHostnames, err := listAllCustomHostnames(iter) if err != nil { convertedError := convertCloudflareError(err) if !errors.Is(convertedError, provider.SoftError) { log.Errorf("zone %q failed to fetch custom hostnames. Please check if \"Cloudflare for SaaS\" is enabled and API key permissions, %v", zoneID, err) } return nil, convertedError } for _, ch := range customHostnames { chs[newCustomHostnameIndex(ch)] = ch } return chs, nil } // processCustomHostnameChanges applies custom hostname side-effects for each // change in the set and returns true if any operation failed. func (p *CloudFlareProvider) processCustomHostnameChanges( ctx context.Context, zoneID string, changes []*cloudFlareChange, chs customHostnamesMap, ) bool { failed := false for _, change := range changes { logFields := log.Fields{ "record": change.ResourceRecord.Name, "type": change.ResourceRecord.Type, "ttl": change.ResourceRecord.TTL, "action": change.Action.String(), "zone": zoneID, } if !p.submitCustomHostnameChanges(ctx, zoneID, change, chs, logFields) { failed = true } } return failed } // listAllCustomHostnames extracts all custom hostnames from the iterator func listAllCustomHostnames(iter autoPager[custom_hostnames.CustomHostnameListResponse]) ([]customHostname, error) { var customHostnames []customHostname for ch := range autoPagerIterator(iter) { customHostnames = append(customHostnames, customHostname{ id: ch.ID, hostname: ch.Hostname, customOriginServer: ch.CustomOriginServer, customOriginSNI: ch.CustomOriginSNI, }) } if iter.Err() != nil { return nil, iter.Err() } return customHostnames, nil } ================================================ FILE: provider/cloudflare/cloudflare_custom_hostnames_test.go ================================================ /* Copyright 2026 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 cloudflare import ( "context" "errors" "fmt" "strings" "testing" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/custom_hostnames" "github.com/cloudflare/cloudflare-go/v5/dns" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/plan" ) func (m *mockCloudFlareClient) CustomHostnames(ctx context.Context, zoneID string) autoPager[custom_hostnames.CustomHostnameListResponse] { if strings.HasPrefix(zoneID, "newerror-") { return &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{ err: errors.New("failed to list custom hostnames"), } } result := []custom_hostnames.CustomHostnameListResponse{} if chs, ok := m.customHostnames[zoneID]; ok { for _, ch := range chs { if strings.HasPrefix(ch.hostname, "newerror-list-") { params := custom_hostnames.CustomHostnameDeleteParams{ZoneID: cloudflare.F(zoneID)} m.DeleteCustomHostname(ctx, ch.id, params) return &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{ err: errors.New("failed to list erroring custom hostname"), } } result = append(result, custom_hostnames.CustomHostnameListResponse{ ID: ch.id, Hostname: ch.hostname, CustomOriginServer: ch.customOriginServer, }) } } return &mockAutoPager[custom_hostnames.CustomHostnameListResponse]{ items: result, } } func (m *mockCloudFlareClient) CreateCustomHostname(_ context.Context, zoneID string, ch customHostname) error { if ch.hostname == "" || ch.customOriginServer == "" || ch.hostname == "newerror-create.foo.fancybar.com" { return fmt.Errorf("Invalid custom hostname or origin hostname") } if _, ok := m.customHostnames[zoneID]; !ok { m.customHostnames[zoneID] = []customHostname{} } newCustomHostname := ch newCustomHostname.id = fmt.Sprintf("ID-%s", ch.hostname) m.customHostnames[zoneID] = append(m.customHostnames[zoneID], newCustomHostname) return nil } func (m *mockCloudFlareClient) DeleteCustomHostname(_ context.Context, customHostnameID string, params custom_hostnames.CustomHostnameDeleteParams) error { zoneID := params.ZoneID.String() idx := 0 if idx = getCustomHostnameIdxByID(m.customHostnames[zoneID], customHostnameID); idx < 0 { return fmt.Errorf("Invalid custom hostname ID to delete") } m.customHostnames[zoneID] = append(m.customHostnames[zoneID][:idx], m.customHostnames[zoneID][idx+1:]...) if customHostnameID == "ID-newerror-delete.foo.fancybar.com" { return fmt.Errorf("Invalid custom hostname to delete") } return nil } func getCustomHostnameIdxByID(chs []customHostname, customHostnameID string) int { for idx, ch := range chs { if ch.id == customHostnameID { return idx } } return -1 } func TestCloudflareCustomHostnameOperations(t *testing.T) { client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true}, } ctx := t.Context() domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) testFailCases := []struct { Name string Endpoints []*endpoint.Endpoint ExpectedCustomHostnames map[string]string }{} for _, tc := range testFailCases { t.Run(tc.Name, func(t *testing.T) { records, err := provider.Records(ctx) if err != nil { t.Errorf("should not fail, %v", err) } endpoints, err := provider.AdjustEndpoints(tc.Endpoints) assert.NoError(t, err) plan := &plan.Plan{ Current: records, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } planned := plan.Calculate() err = provider.ApplyChanges(t.Context(), planned.Changes) if e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) { t.Error(e) } chs, chErr := provider.listCustomHostnamesWithPagination(ctx, "001") if e := checkFailed(tc.Name, chErr, false); !errors.Is(e, nil) { t.Error(e) } actualCustomHostnames := map[string]string{} for _, ch := range chs { actualCustomHostnames[ch.hostname] = ch.customOriginServer } if len(actualCustomHostnames) == 0 { actualCustomHostnames = nil } assert.Equal(t, tc.ExpectedCustomHostnames, actualCustomHostnames, "custom hostnames should be the same") }) } } func TestCloudflareDisabledCustomHostnameOperations(t *testing.T) { client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{Enabled: false}, } ctx := t.Context() domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) testCases := []struct { Name string Endpoints []*endpoint.Endpoint testChanges bool }{ { Name: "add custom hostname", Endpoints: []*endpoint.Endpoint{ { DNSName: "a.foo.bar.com", Targets: endpoint.Targets{"1.2.3.11"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "a.foo.fancybar.com", }, }, }, { DNSName: "b.foo.bar.com", Targets: endpoint.Targets{"1.2.3.12"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, }, { DNSName: "c.foo.bar.com", Targets: endpoint.Targets{"1.2.3.13"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "c1.foo.fancybar.com", }, }, }, }, testChanges: false, }, { Name: "add custom hostname", Endpoints: []*endpoint.Endpoint{ { DNSName: "a.foo.bar.com", Targets: endpoint.Targets{"1.2.3.11"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, }, { DNSName: "b.foo.bar.com", Targets: endpoint.Targets{"1.2.3.12"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "b.foo.fancybar.com", }, }, }, { DNSName: "c.foo.bar.com", Targets: endpoint.Targets{"1.2.3.13"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "c2.foo.fancybar.com", }, }, }, }, testChanges: true, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { records, err := provider.Records(ctx) if err != nil { t.Errorf("should not fail, %v", err) } endpoints, err := provider.AdjustEndpoints(tc.Endpoints) assert.NoError(t, err) plan := &plan.Plan{ Current: records, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } planned := plan.Calculate() err = provider.ApplyChanges(ctx, planned.Changes) if e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) { t.Error(e) } if tc.testChanges { assert.False(t, planned.Changes.HasChanges(), "no new changes should be here") } }) } } func TestCloudflareCustomHostnameNotFoundOnRecordDeletion(t *testing.T) { client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true}, } ctx := t.Context() zoneID := "001" domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) testCases := []struct { Name string Endpoints []*endpoint.Endpoint ExpectedCustomHostnames map[string]string preApplyHook string logOutput string }{ { Name: "create DNS record with custom hostname", Endpoints: []*endpoint.Endpoint{ { DNSName: "create.foo.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "newerror-getCustomHostnameOrigin.foo.fancybar.com", }, }, }, }, preApplyHook: "", logOutput: "", }, { Name: "remove DNS record with unexpectedly missing custom hostname", Endpoints: []*endpoint.Endpoint{}, preApplyHook: "corrupt", logOutput: "failed to delete custom hostname \"newerror-getCustomHostnameOrigin.foo.fancybar.com\": failed to get custom hostname: \"newerror-getCustomHostnameOrigin.foo.fancybar.com\" not found", }, { Name: "duplicate custom hostname", Endpoints: []*endpoint.Endpoint{}, preApplyHook: "duplicate", logOutput: "", }, { Name: "create DNS record with custom hostname", Endpoints: []*endpoint.Endpoint{ { DNSName: "a.foo.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "a.foo.fancybar.com", }, }, }, }, preApplyHook: "", logOutput: "custom hostname \"a.foo.fancybar.com\" already exists with the same origin \"a.foo.bar.com\", continue", }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.InfoLevel, t) records, err := provider.Records(ctx) if err != nil { t.Errorf("should not fail, %v", err) } endpoints, err := provider.AdjustEndpoints(tc.Endpoints) assert.NoError(t, err) plan := &plan.Plan{ Current: records, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } planned := plan.Calculate() // manually corrupt custom hostname before the deletion step // the purpose is to cause getCustomHostnameOrigin() to fail on change.Action == cloudFlareDelete chs, chErr := provider.listCustomHostnamesWithPagination(ctx, zoneID) if e := checkFailed(tc.Name, chErr, false); !errors.Is(e, nil) { t.Error(e) } switch tc.preApplyHook { case "corrupt": if ch, err := getCustomHostname(chs, "newerror-getCustomHostnameOrigin.foo.fancybar.com"); errors.Is(err, nil) { chID := ch.id t.Logf("corrupting custom hostname %q", chID) oldIdx := getCustomHostnameIdxByID(client.customHostnames[zoneID], chID) oldCh := client.customHostnames[zoneID][oldIdx] ch := customHostname{ hostname: "corrupted-newerror-getCustomHostnameOrigin.foo.fancybar.com", customOriginServer: oldCh.customOriginServer, ssl: oldCh.ssl, } client.customHostnames[zoneID][oldIdx] = ch } case "duplicate": // manually inject duplicating custom hostname with the same name and origin ch := customHostname{ id: "ID-random-123", hostname: "a.foo.fancybar.com", customOriginServer: "a.foo.bar.com", } client.customHostnames[zoneID] = append(client.customHostnames[zoneID], ch) } err = provider.ApplyChanges(t.Context(), planned.Changes) if e := checkFailed(tc.Name, err, false); !errors.Is(e, nil) { t.Error(e) } logtest.TestHelperLogContains(tc.logOutput, hook, t) }) } } func TestCloudflareListCustomHostnamesWithPagionation(t *testing.T) { client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true}, } ctx := t.Context() domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) const CustomHostnamesNumber = 342 var generatedEndpoints []*endpoint.Endpoint for i := range CustomHostnamesNumber { ep := []*endpoint.Endpoint{ { DNSName: fmt.Sprintf("host-%d.foo.bar.com", i), Targets: endpoint.Targets{fmt.Sprintf("cname-%d.foo.bar.com", i)}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: fmt.Sprintf("host-%d.foo.fancybar.com", i), }, }, }, } generatedEndpoints = append(generatedEndpoints, ep...) } records, err := provider.Records(ctx) if err != nil { t.Errorf("should not fail, %v", err) } endpoints, err := provider.AdjustEndpoints(generatedEndpoints) assert.NoError(t, err) plan := &plan.Plan{ Current: records, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } planned := plan.Calculate() err = provider.ApplyChanges(t.Context(), planned.Changes) if err != nil { t.Errorf("should not fail - %v", err) } chs, chErr := provider.listCustomHostnamesWithPagination(ctx, "001") if chErr != nil { t.Errorf("should not fail - %v", chErr) } assert.Len(t, chs, CustomHostnamesNumber) } func TestBuildCustomHostnameNewParams(t *testing.T) { t.Run("Minimal custom hostname without SSL", func(t *testing.T) { ch := customHostname{ hostname: "test.example.com", customOriginServer: "origin.example.com", } params := buildCustomHostnameNewParams("zone-123", ch) assert.Equal(t, "zone-123", params.ZoneID.Value) assert.Equal(t, "test.example.com", params.Hostname.Value) assert.False(t, params.SSL.Present) }) t.Run("Custom hostname with full SSL configuration", func(t *testing.T) { ch := customHostname{ hostname: "test.example.com", customOriginServer: "origin.example.com", ssl: &customHostnameSSL{ sslType: "dv", method: "http", bundleMethod: "ubiquitous", certificateAuthority: "digicert", settings: customHostnameSSLSettings{ minTLSVersion: "1.2", }, }, } params := buildCustomHostnameNewParams("zone-123", ch) assert.Equal(t, "zone-123", params.ZoneID.Value) assert.Equal(t, "test.example.com", params.Hostname.Value) assert.True(t, params.SSL.Present) ssl := params.SSL.Value assert.Equal(t, "dv", string(ssl.Type.Value)) assert.Equal(t, "http", string(ssl.Method.Value)) assert.Equal(t, "ubiquitous", string(ssl.BundleMethod.Value)) assert.Equal(t, "digicert", string(ssl.CertificateAuthority.Value)) assert.Equal(t, "1.2", string(ssl.Settings.Value.MinTLSVersion.Value)) }) t.Run("Custom hostname with partial SSL configuration", func(t *testing.T) { ch := customHostname{ hostname: "test.example.com", customOriginServer: "origin.example.com", ssl: &customHostnameSSL{ sslType: "dv", method: "http", }, } params := buildCustomHostnameNewParams("zone-123", ch) assert.True(t, params.SSL.Present) ssl := params.SSL.Value assert.Equal(t, "dv", string(ssl.Type.Value)) assert.Equal(t, "http", string(ssl.Method.Value)) assert.False(t, ssl.BundleMethod.Present) assert.False(t, ssl.CertificateAuthority.Present) assert.False(t, ssl.Settings.Present) }) t.Run("Custom hostname with 'none' certificate authority", func(t *testing.T) { ch := customHostname{ hostname: "test.example.com", customOriginServer: "origin.example.com", ssl: &customHostnameSSL{ sslType: "dv", method: "http", certificateAuthority: "none", }, } params := buildCustomHostnameNewParams("zone-123", ch) assert.True(t, params.SSL.Present) ssl := params.SSL.Value // "none" should not be set as certificate authority assert.False(t, ssl.CertificateAuthority.Present) }) t.Run("Custom hostname with empty certificate authority", func(t *testing.T) { ch := customHostname{ hostname: "test.example.com", customOriginServer: "origin.example.com", ssl: &customHostnameSSL{ sslType: "dv", method: "http", certificateAuthority: "", }, } params := buildCustomHostnameNewParams("zone-123", ch) assert.True(t, params.SSL.Present) ssl := params.SSL.Value // Empty string should not be set assert.False(t, ssl.CertificateAuthority.Present) }) t.Run("Custom hostname with only MinTLSVersion", func(t *testing.T) { ch := customHostname{ hostname: "test.example.com", customOriginServer: "origin.example.com", ssl: &customHostnameSSL{ settings: customHostnameSSLSettings{ minTLSVersion: "1.3", }, }, } params := buildCustomHostnameNewParams("zone-123", ch) assert.True(t, params.SSL.Present) ssl := params.SSL.Value assert.True(t, ssl.Settings.Present) assert.Equal(t, "1.3", string(ssl.Settings.Value.MinTLSVersion.Value)) }) } func TestSubmitCustomHostnameChanges(t *testing.T) { ctx := t.Context() t.Run("CustomHostnames_Disabled", func(t *testing.T) { client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{ Enabled: false, }, } change := &cloudFlareChange{ Action: cloudFlareCreate, } result := provider.submitCustomHostnameChanges(ctx, "zone1", change, nil, nil) assert.True(t, result, "Should return true when custom hostnames are disabled") }) t.Run("CustomHostnames_Create", func(t *testing.T) { client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{ Enabled: true, }, } change := &cloudFlareChange{ Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{ Type: "A", }, CustomHostnames: map[string]customHostname{ "new.example.com": { hostname: "new.example.com", customOriginServer: "origin.example.com", }, }, } chs := make(customHostnamesMap) result := provider.submitCustomHostnameChanges(ctx, "zone1", change, chs, nil) assert.True(t, result, "Should successfully create custom hostname") assert.Len(t, client.customHostnames["zone1"], 1, "One custom hostname should be created") assert.Contains(t, client.customHostnames["zone1"], customHostname{ id: "ID-new.example.com", hostname: "new.example.com", customOriginServer: "origin.example.com", }, "Custom hostname should be created in mock client", ) }) t.Run("CustomHostnames_Create_AlreadyExists", func(t *testing.T) { client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{ Enabled: true, }, } change := &cloudFlareChange{ Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{ Type: "A", }, CustomHostnames: map[string]customHostname{ "exists.example.com": { hostname: "exists.example.com", customOriginServer: "origin.example.com", }, }, } chs := customHostnamesMap{ customHostnameIndex{hostname: "exists.example.com"}: { id: "ch1", hostname: "exists.example.com", customOriginServer: "origin.example.com", }, } client.customHostnames = map[string][]customHostname{ "zone1": { { id: "ch1", hostname: "exists.example.com", customOriginServer: "origin.example.com", }, }, } result := provider.submitCustomHostnameChanges(ctx, "zone1", change, chs, nil) assert.True(t, result, "Should succeed when custom hostname already exists with same origin") assert.Len(t, client.customHostnames["zone1"], 1, "No new custom hostname should be created") assert.Contains(t, client.customHostnames["zone1"], customHostname{ id: "ch1", hostname: "exists.example.com", customOriginServer: "origin.example.com", }, "Existing custom hostname should remain unchanged in mock client", ) }) t.Run("CustomHostnames_Delete", func(_ *testing.T) { client := NewMockCloudFlareClient() client.customHostnames = map[string][]customHostname{ "zone1": { { id: "ch1", hostname: "delete.example.com", customOriginServer: "origin.example.com", }, }, } provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{ Enabled: true, }, } change := &cloudFlareChange{ Action: cloudFlareDelete, ResourceRecord: dns.RecordResponse{ Type: "A", }, CustomHostnames: map[string]customHostname{ "delete.example.com": { hostname: "delete.example.com", }, }, } chs := customHostnamesMap{ customHostnameIndex{hostname: "delete.example.com"}: { id: "ch1", hostname: "delete.example.com", customOriginServer: "origin.example.com", }, } // Note: submitCustomHostnameChanges returns false on failure, true on success // The mock may not find the hostname to delete, which is fine for this test result := provider.submitCustomHostnameChanges(ctx, "zone1", change, chs, nil) // We just verify it doesn't panic - result may be true or false depending on mock behavior _ = result }) t.Run("CustomHostnames_Update", func(t *testing.T) { client := NewMockCloudFlareClient() client.customHostnames = map[string][]customHostname{ "zone1": { { id: "ch1", hostname: "old.example.com", customOriginServer: "origin.example.com", }, }, } provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{ Enabled: true, }, } change := &cloudFlareChange{ Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{ Type: "A", }, CustomHostnames: map[string]customHostname{ "new.example.com": { hostname: "new.example.com", customOriginServer: "origin.example.com", }, }, CustomHostnamesPrev: []string{"old.example.com"}, } chs := customHostnamesMap{ customHostnameIndex{hostname: "old.example.com"}: { id: "ch1", hostname: "old.example.com", customOriginServer: "origin.example.com", }, } client.customHostnames = map[string][]customHostname{ "zone1": { { id: "ch1", hostname: "old.example.com", customOriginServer: "origin.example.com", }, }, } result := provider.submitCustomHostnameChanges(ctx, "zone1", change, chs, nil) assert.True(t, result, "Should successfully update custom hostname") assert.Len(t, client.customHostnames["zone1"], 1, "One custom hostname should exist after update") assert.Contains(t, client.customHostnames["zone1"], customHostname{ id: "ID-new.example.com", hostname: "new.example.com", customOriginServer: "origin.example.com", }, "Custom hostname should be updated in mock client", ) }) } ================================================ FILE: provider/cloudflare/cloudflare_regional.go ================================================ /* 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 cloudflare import ( "context" "fmt" "maps" "slices" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/addressing" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) type RegionalServicesConfig struct { Enabled bool RegionKey string } var recordTypeRegionalHostnameSupported = map[string]bool{ "A": true, "AAAA": true, "CNAME": true, } type regionalHostname struct { hostname string regionKey string } // regionalHostnamesMap is a map of regional hostnames keyed by hostname. type regionalHostnamesMap map[string]regionalHostname type regionalHostnameChange struct { action changeAction regionalHostname } func (z zoneService) ListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] { return z.service.Addressing.RegionalHostnames.ListAutoPaging(ctx, params) } func (z zoneService) CreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error { _, err := z.service.Addressing.RegionalHostnames.New(ctx, params) return err } func (z zoneService) UpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error { _, err := z.service.Addressing.RegionalHostnames.Edit(ctx, hostname, params) return err } func (z zoneService) DeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error { _, err := z.service.Addressing.RegionalHostnames.Delete(ctx, hostname, params) return err } // listDataLocalizationRegionalHostnamesParams is a function that returns the appropriate RegionalHostname List Param based on the zoneID func listDataLocalizationRegionalHostnamesParams(zoneID string) addressing.RegionalHostnameListParams { return addressing.RegionalHostnameListParams{ ZoneID: cloudflare.F(zoneID), } } // createDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in func createDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameNewParams { return addressing.RegionalHostnameNewParams{ ZoneID: cloudflare.F(zoneID), Hostname: cloudflare.F(rhc.hostname), RegionKey: cloudflare.F(rhc.regionKey), } } // updateDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in func updateDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameEditParams { return addressing.RegionalHostnameEditParams{ ZoneID: cloudflare.F(zoneID), RegionKey: cloudflare.F(rhc.regionKey), } } // deleteDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in func deleteDataLocalizationRegionalHostnameParams(zoneID string) addressing.RegionalHostnameDeleteParams { return addressing.RegionalHostnameDeleteParams{ ZoneID: cloudflare.F(zoneID), } } // submitRegionalHostnameChanges applies a set of regional hostname changes, returns false if at least one fails func (p *CloudFlareProvider) submitRegionalHostnameChanges(ctx context.Context, zoneID string, rhChanges []regionalHostnameChange) bool { failedChange := false for _, rhChange := range rhChanges { if !p.submitRegionalHostnameChange(ctx, zoneID, rhChange) { failedChange = true } } return !failedChange } // submitRegionalHostnameChange applies a single regional hostname change, returns false if it fails func (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, zoneID string, rhChange regionalHostnameChange) bool { changeLog := log.WithFields(log.Fields{ "hostname": rhChange.hostname, "region_key": rhChange.regionKey, "action": rhChange.action.String(), "zone": zoneID, }) if p.DryRun { changeLog.Debug("Dry run: skipping regional hostname change", rhChange.action) return true } switch rhChange.action { case cloudFlareCreate: changeLog.Debug("Creating regional hostname") params := createDataLocalizationRegionalHostnameParams(zoneID, rhChange) if err := p.Client.CreateDataLocalizationRegionalHostname(ctx, params); err != nil { changeLog.Errorf("failed to create regional hostname: %v", err) return false } case cloudFlareUpdate: changeLog.Debug("Updating regional hostname") params := updateDataLocalizationRegionalHostnameParams(zoneID, rhChange) if err := p.Client.UpdateDataLocalizationRegionalHostname(ctx, rhChange.hostname, params); err != nil { changeLog.Errorf("failed to update regional hostname: %v", err) return false } case cloudFlareDelete: changeLog.Debug("Deleting regional hostname") params := deleteDataLocalizationRegionalHostnameParams(zoneID) if err := p.Client.DeleteDataLocalizationRegionalHostname(ctx, rhChange.hostname, params); err != nil { changeLog.Errorf("failed to delete regional hostname: %v", err) return false } } return true } // listDataLocalisationRegionalHostnames fetches the current regional hostnames for the given zone ID. // // It returns a map of hostnames to regional hostnames, or an error if the request fails. func (p *CloudFlareProvider) listDataLocalisationRegionalHostnames(ctx context.Context, zoneID string) (regionalHostnamesMap, error) { params := listDataLocalizationRegionalHostnamesParams(zoneID) iter := p.Client.ListDataLocalizationRegionalHostnames(ctx, params) rhsMap := make(regionalHostnamesMap) for rh := range autoPagerIterator(iter) { rhsMap[rh.Hostname] = regionalHostname{ hostname: rh.Hostname, regionKey: rh.RegionKey, } } if iter.Err() != nil { return nil, convertCloudflareError(iter.Err()) } return rhsMap, nil } // regionalHostname returns a regionalHostname for the given endpoint. // // If the regional services feature is not enabled or the record type does not support regional hostnames, // it returns an empty regionalHostname. // If the endpoint has a specific region key set, it uses that; otherwise, it defaults to the region key configured in the provider. func (p *CloudFlareProvider) regionalHostname(ep *endpoint.Endpoint) regionalHostname { if !p.RegionalServicesConfig.Enabled || !recordTypeRegionalHostnameSupported[ep.RecordType] { return regionalHostname{} } regionKey := p.RegionalServicesConfig.RegionKey if epRegionKey, exists := ep.GetProviderSpecificProperty(annotations.CloudflareRegionKey); exists { regionKey = epRegionKey } return regionalHostname{ hostname: ep.DNSName, regionKey: regionKey, } } // addEnpointsProviderSpecificRegionKeyProperty fetch the regional hostnames on cloudflare and // adds Cloudflare-specific region keys to the provided endpoints. // // Do nothing if the regional services feature is not enabled. // Defaults to the region key configured in the provider config if not found in the regional hostnames. func (p *CloudFlareProvider) addEnpointsProviderSpecificRegionKeyProperty(ctx context.Context, zoneID string, endpoints []*endpoint.Endpoint) error { if !p.RegionalServicesConfig.Enabled { return nil } // Filter endpoints to only those that support regional hostnames // so we can skip regional hostname lookups if not needed. var supportedEndpoints []*endpoint.Endpoint for _, ep := range endpoints { if recordTypeRegionalHostnameSupported[ep.RecordType] { supportedEndpoints = append(supportedEndpoints, ep) } } if len(supportedEndpoints) == 0 { return nil } regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID) if err != nil { return err } for _, ep := range supportedEndpoints { var regionKey string if rh, found := regionalHostnames[ep.DNSName]; found { regionKey = rh.regionKey } ep.SetProviderSpecificProperty(annotations.CloudflareRegionKey, regionKey) } return nil } // adjustEnpointProviderSpecificRegionKeyProperty updates the given endpoint's provider-specific // Cloudflare region key based on the provider's RegionalServicesConfig. // - If regional services are disabled or the endpoint's record type does not // support regional hostnames, the Cloudflare region key is removed. // - If enabled and supported, and the key is not already set, it is initialized // to the provider's default RegionKey. // // The endpoint is modified in place and any explicitly set region key is left unchanged. func (p *CloudFlareProvider) adjustEndpointProviderSpecificRegionKeyProperty(ep *endpoint.Endpoint) { if !p.RegionalServicesConfig.Enabled || !recordTypeRegionalHostnameSupported[ep.RecordType] { ep.DeleteProviderSpecificProperty(annotations.CloudflareRegionKey) return } // Add default region key if not set if _, ok := ep.GetProviderSpecificProperty(annotations.CloudflareRegionKey); !ok { ep.SetProviderSpecificProperty(annotations.CloudflareRegionKey, p.RegionalServicesConfig.RegionKey) } } // desiredRegionalHostnames builds a list of desired regional hostnames from changes. // // If there is a delete and a create or update action for the same hostname, // The create or update takes precedence. // Returns an error for conflicting region keys. func desiredRegionalHostnames(changes []*cloudFlareChange) ([]regionalHostname, error) { rhs := make(map[string]regionalHostname) for _, change := range changes { if change.RegionalHostname.hostname == "" { continue } rh, found := rhs[change.RegionalHostname.hostname] if !found { if change.Action == cloudFlareDelete { rhs[change.RegionalHostname.hostname] = regionalHostname{ hostname: change.RegionalHostname.hostname, regionKey: "", // Indicate that this regional hostname should not exists } continue } rhs[change.RegionalHostname.hostname] = change.RegionalHostname continue } if change.Action == cloudFlareDelete { // A previous regional hostname exists so we can skip this delete action continue } if rh.regionKey == "" { // If the existing regional hostname has no region key, we can overwrite it rhs[change.RegionalHostname.hostname] = change.RegionalHostname continue } if rh.regionKey != change.RegionalHostname.regionKey { return nil, fmt.Errorf("conflicting region keys for regional hostname %q: %q and %q", change.RegionalHostname.hostname, rh.regionKey, change.RegionalHostname.regionKey) } } return slices.Collect(maps.Values(rhs)), nil } // regionalHostnamesChanges build a list of changes needed to synchronize the current regional hostnames state with the desired state. func regionalHostnamesChanges(desired []regionalHostname, regionalHostnames regionalHostnamesMap) []regionalHostnameChange { changes := make([]regionalHostnameChange, 0) for _, rh := range desired { current, found := regionalHostnames[rh.hostname] if rh.regionKey == "" { // If the region key is empty, we don't want a regional hostname if !found { continue } changes = append(changes, regionalHostnameChange{ action: cloudFlareDelete, regionalHostname: rh, }) continue } if !found { changes = append(changes, regionalHostnameChange{ action: cloudFlareCreate, regionalHostname: rh, }) continue } if rh.regionKey != current.regionKey { changes = append(changes, regionalHostnameChange{ action: cloudFlareUpdate, regionalHostname: rh, }) } } return changes } ================================================ FILE: provider/cloudflare/cloudflare_regional_test.go ================================================ /* 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 cloudflare import ( "context" "fmt" "sort" "strings" "testing" "github.com/cloudflare/cloudflare-go/v5/addressing" "github.com/cloudflare/cloudflare-go/v5/dns" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/source/annotations" ) func (m *mockCloudFlareClient) ListDataLocalizationRegionalHostnames(_ context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] { zoneID := params.ZoneID.Value if strings.Contains(zoneID, "rherror") { return &mockAutoPager[addressing.RegionalHostnameListResponse]{err: fmt.Errorf("failed to list regional hostnames")} } results := make([]addressing.RegionalHostnameListResponse, 0, len(m.regionalHostnames[zoneID])) for _, rh := range m.regionalHostnames[zoneID] { results = append(results, addressing.RegionalHostnameListResponse{ Hostname: rh.hostname, RegionKey: rh.regionKey, }) } return &mockAutoPager[addressing.RegionalHostnameListResponse]{ items: results, } } func (m *mockCloudFlareClient) CreateDataLocalizationRegionalHostname(_ context.Context, params addressing.RegionalHostnameNewParams) error { if strings.Contains(params.Hostname.Value, "rherror") { return fmt.Errorf("failed to create regional hostname") } m.Actions = append(m.Actions, MockAction{ Name: "CreateDataLocalizationRegionalHostname", ZoneId: params.ZoneID.Value, RecordId: "", RegionalHostname: regionalHostname{ hostname: params.Hostname.Value, regionKey: params.RegionKey.Value, }, }) return nil } func (m *mockCloudFlareClient) UpdateDataLocalizationRegionalHostname(_ context.Context, hostname string, params addressing.RegionalHostnameEditParams) error { if strings.Contains(hostname, "rherror") { return fmt.Errorf("failed to update regional hostname") } m.Actions = append(m.Actions, MockAction{ Name: "UpdateDataLocalizationRegionalHostname", ZoneId: params.ZoneID.Value, RecordId: "", RegionalHostname: regionalHostname{ hostname: hostname, regionKey: params.RegionKey.Value, }, }) return nil } func (m *mockCloudFlareClient) DeleteDataLocalizationRegionalHostname(_ context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error { if strings.Contains(hostname, "rherror") { return fmt.Errorf("failed to delete regional hostname") } m.Actions = append(m.Actions, MockAction{ Name: "DeleteDataLocalizationRegionalHostname", ZoneId: params.ZoneID.Value, RecordId: "", RegionalHostname: regionalHostname{ hostname: hostname, }, }) return nil } func TestCloudflareRegionalHostnameActions(t *testing.T) { tests := []struct { name string records map[string]dns.RecordResponse regionalHostnames []regionalHostname endpoints []*endpoint.Endpoint want []MockAction }{ { name: "create", records: map[string]dns.RecordResponse{}, regionalHostnames: []regionalHostname{}, endpoints: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "create.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu", }, }, }, }, want: []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "create.bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "create.bar.com", "127.0.0.1"), Type: "A", Name: "create.bar.com", Content: "127.0.0.1", TTL: 1, Proxied: false, }, }, { Name: "CreateDataLocalizationRegionalHostname", ZoneId: "001", RegionalHostname: regionalHostname{ hostname: "create.bar.com", regionKey: "eu", }, }, }, }, { name: "Update", records: map[string]dns.RecordResponse{ "update.bar.com": { ID: generateDNSRecordID("A", "update.bar.com", "127.0.0.1"), Type: "A", Name: "update.bar.com", Content: "127.0.0.1", TTL: 1, Proxied: false, }, }, regionalHostnames: []regionalHostname{ { hostname: "update.bar.com", regionKey: "us", }, }, endpoints: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "update.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu", }, }, }, }, want: []MockAction{ { Name: "Update", ZoneId: "001", RecordId: generateDNSRecordID("A", "update.bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "update.bar.com", "127.0.0.1"), Type: "A", Name: "update.bar.com", Content: "127.0.0.1", TTL: 1, Proxied: false, }, }, { Name: "UpdateDataLocalizationRegionalHostname", ZoneId: "001", RegionalHostname: regionalHostname{ hostname: "update.bar.com", regionKey: "eu", }, }, }, }, { name: "Delete", records: map[string]dns.RecordResponse{ "update.bar.com": { ID: generateDNSRecordID("A", "delete.bar.com", "127.0.0.1"), Type: "A", Name: "delete.bar.com", Content: "127.0.0.1", TTL: 1, Proxied: false, }, }, regionalHostnames: []regionalHostname{ { hostname: "delete.bar.com", regionKey: "us", }, }, endpoints: []*endpoint.Endpoint{}, want: []MockAction{ { Name: "Delete", ZoneId: "001", RecordId: generateDNSRecordID("A", "delete.bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{}, }, { Name: "DeleteDataLocalizationRegionalHostname", ZoneId: "001", RegionalHostname: regionalHostname{ hostname: "delete.bar.com", }, }, }, }, { name: "No change", records: map[string]dns.RecordResponse{ "nochange.bar.com": { ID: generateDNSRecordID("A", "nochange.bar.com", "127.0.0.1"), Type: "A", Name: "nochange.bar.com", Content: "127.0.0.1", TTL: 1, Proxied: false, }, }, regionalHostnames: []regionalHostname{ { hostname: "nochange.bar.com", regionKey: "eu", }, }, endpoints: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "nochange.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu", }, }, }, }, want: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { provider := &CloudFlareProvider{ RegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}, Client: &mockCloudFlareClient{ Zones: map[string]string{ "001": "bar.com", }, Records: map[string]map[string]dns.RecordResponse{ "001": tt.records, }, regionalHostnames: map[string][]regionalHostname{ "001": tt.regionalHostnames, }, }, } AssertActions(t, provider, tt.endpoints, tt.want, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}) }) } } func TestCloudflareRegionalHostnameDefaults(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "A", DNSName: "bar.com", Targets: endpoint.Targets{"127.0.0.1", "127.0.0.2"}, }, } AssertActions(t, &CloudFlareProvider{RegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"), Type: "A", Name: "bar.com", Content: "127.0.0.1", TTL: 1, Proxied: false, }, }, { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.2"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "bar.com", "127.0.0.2"), Type: "A", Name: "bar.com", Content: "127.0.0.2", TTL: 1, Proxied: false, }, }, { Name: "CreateDataLocalizationRegionalHostname", ZoneId: "001", RegionalHostname: regionalHostname{ hostname: "bar.com", regionKey: "us", }, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, ) } func Test_regionalHostname(t *testing.T) { type args struct { endpoint *endpoint.Endpoint config RegionalServicesConfig } tests := []struct { name string args args want regionalHostname }{ { name: "no region key", args: args{ endpoint: &endpoint.Endpoint{ RecordType: "A", DNSName: "example.com", }, config: RegionalServicesConfig{ Enabled: true, RegionKey: "", }, }, want: regionalHostname{ hostname: "example.com", regionKey: "", }, }, { name: "default region key", args: args{ endpoint: &endpoint.Endpoint{ RecordType: "A", DNSName: "example.com", }, config: RegionalServicesConfig{ Enabled: true, RegionKey: "us", }, }, want: regionalHostname{ hostname: "example.com", regionKey: "us", }, }, { name: "endpoint with region key", args: args{ endpoint: &endpoint.Endpoint{ RecordType: "A", DNSName: "example.com", ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu", }, }, }, config: RegionalServicesConfig{ Enabled: true, RegionKey: "us", }, }, want: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, { name: "endpoint with empty region key", args: args{ endpoint: &endpoint.Endpoint{ RecordType: "A", DNSName: "example.com", ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "", }, }, }, config: RegionalServicesConfig{ Enabled: true, RegionKey: "us", }, }, want: regionalHostname{ hostname: "example.com", regionKey: "", }, }, { name: "unsupported record type", args: args{ endpoint: &endpoint.Endpoint{ RecordType: "TXT", DNSName: "example.com", ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu", }, }, }, config: RegionalServicesConfig{ Enabled: true, RegionKey: "us", }, }, want: regionalHostname{}, }, { name: "disabled", args: args{ endpoint: &endpoint.Endpoint{ RecordType: "A", DNSName: "example.com", ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "us", }, }, }, config: RegionalServicesConfig{ Enabled: false, }, }, want: regionalHostname{ hostname: "", regionKey: "", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := CloudFlareProvider{RegionalServicesConfig: tt.args.config} got := p.regionalHostname(tt.args.endpoint) assert.Equal(t, tt.want, got) }) } } func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { tests := []struct { name string changes []*cloudFlareChange want []regionalHostname wantErr bool }{ { name: "empty input", changes: []*cloudFlareChange{}, want: nil, wantErr: false, }, { name: "change without regional hostname config", changes: []*cloudFlareChange{{ Action: cloudFlareCreate, }}, want: nil, wantErr: false, }, { name: "changes with same hostname and region key", changes: []*cloudFlareChange{ { Action: cloudFlareCreate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, { Action: cloudFlareUpdate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, }, want: []regionalHostname{ { hostname: "example.com", regionKey: "eu", }, }, wantErr: false, }, { name: "changes with same hostname but different region keys", changes: []*cloudFlareChange{ { Action: cloudFlareCreate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, { Action: cloudFlareUpdate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "us", // Different region key }, }, }, want: nil, wantErr: true, }, { name: "changes with different hostnames", changes: []*cloudFlareChange{ { Action: cloudFlareCreate, RegionalHostname: regionalHostname{ hostname: "example1.com", regionKey: "eu", }, }, { Action: cloudFlareUpdate, RegionalHostname: regionalHostname{ hostname: "example2.com", regionKey: "us", }, }, { Action: cloudFlareDelete, RegionalHostname: regionalHostname{ hostname: "example3.com", regionKey: "us", }, }, }, want: []regionalHostname{ { hostname: "example1.com", regionKey: "eu", }, { hostname: "example2.com", regionKey: "us", }, { hostname: "example3.com", regionKey: "", }, }, wantErr: false, }, { name: "change with empty region key", changes: []*cloudFlareChange{ { Action: cloudFlareCreate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "", // Empty region key }, }, }, want: []regionalHostname{ { hostname: "example.com", regionKey: "", }, }, wantErr: false, }, { name: "empty region key followed by region key", changes: []*cloudFlareChange{ { Action: cloudFlareCreate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "", // Empty region key }, }, { Action: cloudFlareUpdate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, }, want: []regionalHostname{ { hostname: "example.com", regionKey: "eu", }, }, wantErr: false, }, { name: "region key followed by empty region key", changes: []*cloudFlareChange{ { Action: cloudFlareCreate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, { Action: cloudFlareUpdate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", // Empty region key }, }, }, want: []regionalHostname{ { hostname: "example.com", regionKey: "eu", }, }, wantErr: false, }, { name: "delete followed by create for the same hostname", changes: []*cloudFlareChange{ { Action: cloudFlareDelete, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, { Action: cloudFlareCreate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, }, want: []regionalHostname{ { hostname: "example.com", regionKey: "eu", }, }, wantErr: false, }, { name: "create followed by delete for the same hostname", changes: []*cloudFlareChange{ { Action: cloudFlareCreate, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, { Action: cloudFlareDelete, RegionalHostname: regionalHostname{ hostname: "example.com", regionKey: "eu", }, }, }, want: []regionalHostname{ { hostname: "example.com", regionKey: "eu", }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := desiredRegionalHostnames(tt.changes) if tt.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) } sort.Slice(got, func(i, j int) bool { return got[i].hostname < got[j].hostname }) sort.Slice(tt.want, func(i, j int) bool { return tt.want[i].hostname < tt.want[j].hostname }) assert.Equal(t, tt.want, got) }) } } func Test_dataLocalizationRegionalHostnamesChanges(t *testing.T) { tests := []struct { name string desired []regionalHostname regionalHostnames regionalHostnamesMap want []regionalHostnameChange }{ { name: "empty desired and current lists", desired: []regionalHostname{}, regionalHostnames: regionalHostnamesMap{}, want: []regionalHostnameChange{}, }, { name: "multiple changes", desired: []regionalHostname{ { hostname: "create.example.com", regionKey: "eu", }, { hostname: "update.example.com", regionKey: "eu", }, { hostname: "delete.example.com", regionKey: "", }, { hostname: "nochange.example.com", regionKey: "us", }, { hostname: "absent.example.com", regionKey: "", }, }, regionalHostnames: regionalHostnamesMap{ "update.example.com": regionalHostname{ hostname: "update.example.com", regionKey: "us", }, "delete.example.com": regionalHostname{ hostname: "delete.example.com", regionKey: "ap", }, "nochange.example.com": regionalHostname{ hostname: "nochange.example.com", regionKey: "us", }, }, want: []regionalHostnameChange{ { action: cloudFlareCreate, regionalHostname: regionalHostname{ hostname: "create.example.com", regionKey: "eu", }, }, { action: cloudFlareUpdate, regionalHostname: regionalHostname{ hostname: "update.example.com", regionKey: "eu", }, }, { action: cloudFlareDelete, regionalHostname: regionalHostname{ hostname: "delete.example.com", regionKey: "", }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := regionalHostnamesChanges(tt.desired, tt.regionalHostnames) assert.Equal(t, tt.want, got) }) } } func TestRecordsWithListRegionalHostnameFaillure(t *testing.T) { client := &mockCloudFlareClient{ Zones: map[string]string{ "rherror": "error.com", }, Records: map[string]map[string]dns.RecordResponse{ "rherror": {"foo.error.com": {Type: "A"}}, }, } failingProvider := &CloudFlareProvider{ Client: client, RegionalServicesConfig: RegionalServicesConfig{Enabled: true}, } _, err := failingProvider.Records(t.Context()) assert.Error(t, err, "listing regional hostnames should fail") } func TestApplyChangesWithRegionalHostnamesFaillures(t *testing.T) { t.Parallel() type fields struct { Records map[string]dns.RecordResponse RegionalHostnames []regionalHostname RegionKey string } type args struct { changes *plan.Changes } tests := []struct { name string fields fields args args errMsg string expectDebug string }{ { name: "list zone fails", args: args{ changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "foo.error.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu"}, }, }, }, }, }, errMsg: "failed to list regional hostnames", }, { name: "create fails", fields: fields{ Records: map[string]dns.RecordResponse{}, RegionalHostnames: []regionalHostname{}, RegionKey: "us", }, args: args{ changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "rherror.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu"}, }, }, }, }, }, expectDebug: "failed to create regional hostname", }, { name: "update fails", fields: fields{ Records: map[string]dns.RecordResponse{ "rherror.bar.com": { ID: "123", Type: "A", Name: "rherror.bar.com", Content: "127.0.0.1", }, }, RegionalHostnames: []regionalHostname{ {hostname: "rherror.bar.com", regionKey: "us"}, }, RegionKey: "us", }, args: args{ changes: &plan.Changes{ UpdateOld: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "rherror.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu"}, }, }, }, UpdateNew: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "rherror.bar.com", Targets: endpoint.Targets{"127.0.0.2"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu"}, }, }, }, }, }, expectDebug: "failed to update regional hostname", }, { name: "delete fails", fields: fields{ Records: map[string]dns.RecordResponse{ "rherror.bar.com": { ID: "123", Type: "A", Name: "newerror.bar.com", Content: "127.0.0.1", }, }, RegionalHostnames: []regionalHostname{ {hostname: "rherror.bar.com", regionKey: "us"}, }, RegionKey: "us", }, args: args{ changes: &plan.Changes{ Delete: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "rherror.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, }, }, }, }, expectDebug: "failed to delete regional hostname", }, { // This should not happen in practice, but we test it to ensure we return an error. name: "conflicting regional keys", fields: fields{ Records: map[string]dns.RecordResponse{}, RegionalHostnames: []regionalHostname{}, RegionKey: "us", }, args: args{ changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "foo.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu"}, }, }, { RecordType: "A", DNSName: "foo.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "us"}, }, }, }, }, }, errMsg: "conflicting region keys", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() records := tt.fields.Records if records == nil { records = map[string]dns.RecordResponse{} } p := &CloudFlareProvider{ Client: &mockCloudFlareClient{ Zones: map[string]string{ "001": "bar.com", "rherror": "error.com", }, Records: map[string]map[string]dns.RecordResponse{ "001": records, }, regionalHostnames: map[string][]regionalHostname{ "001": tt.fields.RegionalHostnames, }, }, RegionalServicesConfig: RegionalServicesConfig{ Enabled: true, RegionKey: tt.fields.RegionKey, }, } hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) err := p.ApplyChanges(t.Context(), tt.args.changes) assert.Error(t, err, "ApplyChanges should return an error") if tt.errMsg != "" && err != nil { assert.Contains(t, err.Error(), tt.errMsg, "Expected error message to contain: %s", tt.errMsg) } if tt.expectDebug != "" { logtest.TestHelperLogContains(tt.expectDebug, hook, t) } }) } } func TestApplyChangesWithRegionalHostnamesDryRun(t *testing.T) { t.Parallel() type fields struct { Records map[string]dns.RecordResponse RegionalHostnames []regionalHostname RegionKey string } type args struct { changes *plan.Changes } tests := []struct { name string fields fields args args expectDebug string }{ { name: "create dry run", fields: fields{ Records: map[string]dns.RecordResponse{}, RegionalHostnames: []regionalHostname{}, RegionKey: "us", }, args: args{ changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "foo.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu"}, }, }, }, }, }, expectDebug: "Dry run: skipping regional hostname change", }, { name: "update fails", fields: fields{ Records: map[string]dns.RecordResponse{ "foo.bar.com": { ID: "123", Type: "A", Name: "foo.bar.com", Content: "127.0.0.1", }, }, RegionalHostnames: []regionalHostname{ {hostname: "foo.bar.com", regionKey: "us"}, }, RegionKey: "us", }, args: args{ changes: &plan.Changes{ UpdateOld: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "foo.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu"}, }, }, }, UpdateNew: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "foo.bar.com", Targets: endpoint.Targets{"127.0.0.2"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-region-key", Value: "eu"}, }, }, }, }, }, expectDebug: "Dry run: skipping regional hostname change", }, { name: "delete fails", fields: fields{ Records: map[string]dns.RecordResponse{ "foo.bar.com": { ID: "123", Type: "A", Name: "foo.bar.com", Content: "127.0.0.1", }, }, RegionalHostnames: []regionalHostname{ {hostname: "foo.bar.com", regionKey: "us"}, }, RegionKey: "us", }, args: args{ changes: &plan.Changes{ Delete: []*endpoint.Endpoint{ { RecordType: "A", DNSName: "foo.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, }, }, }, }, expectDebug: "Dry run: skipping regional hostname change", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() records := tt.fields.Records if records == nil { records = map[string]dns.RecordResponse{} } p := &CloudFlareProvider{ DryRun: true, Client: &mockCloudFlareClient{ Zones: map[string]string{ "001": "bar.com", }, Records: map[string]map[string]dns.RecordResponse{ "001": records, }, regionalHostnames: map[string][]regionalHostname{ "001": tt.fields.RegionalHostnames, }, }, RegionalServicesConfig: RegionalServicesConfig{ Enabled: true, RegionKey: tt.fields.RegionKey, }, } hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) err := p.ApplyChanges(t.Context(), tt.args.changes) assert.NoError(t, err, "ApplyChanges should not fail") if tt.expectDebug != "" { logtest.TestHelperLogContains(tt.expectDebug, hook, t) } }) } } func TestCloudflareAdjustEndpointsRegionalServices(t *testing.T) { testCases := []struct { name string recordType string regionalServicesConfig RegionalServicesConfig initialRegionKey string // existing region key on endpoint expectedRegionKey *string // expected region key after AdjustEndpoints (nil = should not be present) }{ // Supported types should get region key when enabled { name: "A record with regional services enabled", recordType: "A", regionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}, initialRegionKey: "", expectedRegionKey: testutils.ToPtr("us"), }, { name: "AAAA record with regional services enabled", recordType: "AAAA", regionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}, initialRegionKey: "", expectedRegionKey: testutils.ToPtr("us"), }, { name: "CNAME record with regional services enabled", recordType: "CNAME", regionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}, initialRegionKey: "", expectedRegionKey: testutils.ToPtr("us"), }, // Unsupported types should NOT get region key even when enabled { name: "TXT record with regional services enabled", recordType: "TXT", regionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}, initialRegionKey: "", expectedRegionKey: nil, }, // Disabled regional services should remove region key for all types { name: "A record with regional services disabled", recordType: "A", regionalServicesConfig: RegionalServicesConfig{Enabled: false}, initialRegionKey: "existing-region", expectedRegionKey: nil, }, { name: "TXT record with regional services disabled", recordType: "TXT", regionalServicesConfig: RegionalServicesConfig{Enabled: false}, initialRegionKey: "existing-region", expectedRegionKey: nil, }, // Existing region key should be preserved when already set { name: "A record with existing custom region key", recordType: "A", regionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}, initialRegionKey: "eu", expectedRegionKey: testutils.ToPtr("eu"), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create endpoint with initial region key if specified testEndpoint := &endpoint.Endpoint{ RecordType: tc.recordType, DNSName: "test.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, } if tc.initialRegionKey != "" { testEndpoint.ProviderSpecific = endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: annotations.CloudflareRegionKey, Value: tc.initialRegionKey, }, } } provider := &CloudFlareProvider{ RegionalServicesConfig: tc.regionalServicesConfig, } adjustedEndpoints, err := provider.AdjustEndpoints([]*endpoint.Endpoint{testEndpoint}) assert.NoError(t, err) assert.Len(t, adjustedEndpoints, 1) regionKey, exists := adjustedEndpoints[0].GetProviderSpecificProperty(annotations.CloudflareRegionKey) if tc.expectedRegionKey != nil { // Region key should be present with expected value assert.True(t, exists, "Region key should be present") assert.Equal(t, *tc.expectedRegionKey, regionKey, "Region key value should match expected") } else { // Region key should not be present assert.False(t, exists, "Region key should not be present") } }) } } // TestSubmitChanges_DryRun_RegionalErrors covers error paths in the dry-run branch of // submitChanges. Two statements in that branch are not covered by any test: // // - the `failedChange = true` body inside `if !p.submitRegionalHostnameChanges(...)` // - the subsequent `failedZones = append(...)` when failedChange is true // // Both are unreachable because submitRegionalHostnameChange unconditionally returns true // when DryRun=true (it logs and returns before making any API call), so the failure // branch can never be entered without changing the production code. func TestSubmitChanges_DryRun_RegionalErrors(t *testing.T) { t.Run("desiredRegionalHostnames conflict returns error", func(t *testing.T) { // Two changes for the same hostname with different region keys → // desiredRegionalHostnames returns a conflict error. client := NewMockCloudFlareClient() p := &CloudFlareProvider{ Client: client, DryRun: true, RegionalServicesConfig: RegionalServicesConfig{ Enabled: true, }, } // Build conflicting cloudFlareChanges directly and call submitChanges, // which is in the same package. changes := []*cloudFlareChange{ { Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{Name: "foo.bar.com", Type: "A", Content: "1.2.3.4"}, RegionalHostname: regionalHostname{ hostname: "foo.bar.com", regionKey: "us", }, }, { Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{Name: "foo.bar.com", Type: "A", Content: "1.2.3.4"}, RegionalHostname: regionalHostname{ hostname: "foo.bar.com", regionKey: "eu", // different from "us" → conflict }, }, } err := p.submitChanges(t.Context(), changes) require.Error(t, err) assert.Contains(t, err.Error(), "failed to build desired regional hostnames") }) t.Run("listDataLocalisationRegionalHostnames error in dry-run returns error", func(t *testing.T) { // Zone ID containing "rherror" causes the mock to return an error from // ListDataLocalizationRegionalHostnames. client := &mockCloudFlareClient{ Zones: map[string]string{ "rherror-zone1": "rherror.bar.com", }, Records: map[string]map[string]dns.RecordResponse{ "rherror-zone1": {}, }, customHostnames: map[string][]customHostname{}, regionalHostnames: map[string][]regionalHostname{}, } p := &CloudFlareProvider{ Client: client, DryRun: true, RegionalServicesConfig: RegionalServicesConfig{ Enabled: true, RegionKey: "us", }, domainFilter: endpoint.NewDomainFilter([]string{"rherror.bar.com"}), } changes := []*cloudFlareChange{ { Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{Name: "foo.rherror.bar.com", Type: "A", Content: "1.2.3.4"}, RegionalHostname: regionalHostname{ hostname: "foo.rherror.bar.com", regionKey: "us", }, }, } err := p.submitChanges(t.Context(), changes) require.Error(t, err) assert.Contains(t, err.Error(), "could not fetch regional hostnames from zone") }) } ================================================ FILE: provider/cloudflare/cloudflare_test.go ================================================ /* Copyright 2017 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 cloudflare import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "slices" "strings" "testing" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/dns" "github.com/cloudflare/cloudflare-go/v5/option" "github.com/cloudflare/cloudflare-go/v5/zones" "github.com/maxatome/go-testdeep/td" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/source/annotations" ) // newCloudflareError creates a cloudflare.Error suitable for testing. // The v5 SDK's Error type panics when .Error() is called with nil Request/Response fields, // so this helper initializes them properly. func newCloudflareError(statusCode int) *cloudflare.Error { req := httptest.NewRequest(http.MethodGet, "https://api.cloudflare.com/client/v4/zones", nil) resp := &http.Response{ StatusCode: statusCode, Status: http.StatusText(statusCode), Request: req, } return &cloudflare.Error{ StatusCode: statusCode, Request: req, Response: resp, } } var ExampleDomain = []dns.RecordResponse{ { ID: "1234567890", Name: "foobar.bar.com", Type: endpoint.RecordTypeA, TTL: 120, Content: "1.2.3.4", Proxied: false, Comment: "valid comment", }, { ID: "2345678901", Name: "foobar.bar.com", Type: endpoint.RecordTypeA, TTL: 120, Content: "3.4.5.6", Proxied: false, }, { ID: "1231231233", Name: "bar.foo.com", Type: endpoint.RecordTypeA, TTL: 1, Content: "2.3.4.5", Proxied: false, }, } type MockAction struct { Name string ZoneId string RecordId string RecordData dns.RecordResponse RegionalHostname regionalHostname } type mockCloudFlareClient struct { Zones map[string]string Records map[string]map[string]dns.RecordResponse Actions []MockAction BatchDNSRecordsCalls int listZonesError error // For v4 ListZones getZoneError error // For v4 GetZone dnsRecordsError error customHostnames map[string][]customHostname regionalHostnames map[string][]regionalHostname dnsRecordsListParams dns.RecordListParams } func NewMockCloudFlareClient() *mockCloudFlareClient { return &mockCloudFlareClient{ Zones: map[string]string{ "001": "bar.com", "002": "foo.com", }, Records: map[string]map[string]dns.RecordResponse{ "001": {}, "002": {}, }, customHostnames: map[string][]customHostname{}, regionalHostnames: map[string][]regionalHostname{}, } } func NewMockCloudFlareClientWithRecords(records map[string][]dns.RecordResponse) *mockCloudFlareClient { m := NewMockCloudFlareClient() for zoneID, zoneRecords := range records { if zone, ok := m.Records[zoneID]; ok { for _, record := range zoneRecords { zone[record.ID] = record } } } return m } func (m *mockCloudFlareClient) CreateDNSRecord(_ context.Context, params dns.RecordNewParams) (*dns.RecordResponse, error) { body := params.Body.(dns.RecordNewParamsBody) record := dns.RecordResponse{ ID: generateDNSRecordID(body.Type.String(), body.Name.Value, body.Content.Value), Name: body.Name.Value, TTL: dns.TTL(body.TTL.Value), Proxied: body.Proxied.Value, Type: dns.RecordResponseType(body.Type.String()), Content: body.Content.Value, Priority: body.Priority.Value, } m.Actions = append(m.Actions, MockAction{ Name: "Create", ZoneId: params.ZoneID.Value, RecordId: record.ID, RecordData: record, }) if zone, ok := m.Records[params.ZoneID.Value]; ok { zone[record.ID] = record } if record.Name == "newerror.bar.com" { return nil, fmt.Errorf("failed to create record") } return &record, nil } func (m *mockCloudFlareClient) ListDNSRecords(ctx context.Context, params dns.RecordListParams) autoPager[dns.RecordResponse] { m.dnsRecordsListParams = params if m.dnsRecordsError != nil { return &mockAutoPager[dns.RecordResponse]{err: m.dnsRecordsError} } iter := &mockAutoPager[dns.RecordResponse]{} if zone, ok := m.Records[params.ZoneID.Value]; ok { for _, record := range zone { if strings.HasPrefix(record.Name, "newerror-list-") { m.DeleteDNSRecord(ctx, record.ID, dns.RecordDeleteParams{ZoneID: params.ZoneID}) iter.err = errors.New("failed to list erroring DNS record") return iter } iter.items = append(iter.items, record) } } return iter } func (m *mockCloudFlareClient) UpdateDNSRecord(_ context.Context, recordID string, params dns.RecordUpdateParams) (*dns.RecordResponse, error) { zoneID := params.ZoneID.String() body := params.Body.(dns.RecordUpdateParamsBody) record := dns.RecordResponse{ ID: recordID, Name: body.Name.Value, TTL: dns.TTL(body.TTL.Value), Proxied: body.Proxied.Value, Type: dns.RecordResponseType(body.Type.String()), Content: body.Content.Value, Priority: body.Priority.Value, } m.Actions = append(m.Actions, MockAction{ Name: "Update", ZoneId: zoneID, RecordId: recordID, RecordData: record, }) if zone, ok := m.Records[zoneID]; ok { if _, ok := zone[recordID]; ok { if strings.HasPrefix(record.Name, "newerror-update-") { return nil, errors.New("failed to update erroring DNS record") } zone[recordID] = record } } return &record, nil } func (m *mockCloudFlareClient) DeleteDNSRecord(_ context.Context, recordID string, params dns.RecordDeleteParams) error { zoneID := params.ZoneID.String() m.Actions = append(m.Actions, MockAction{ Name: "Delete", ZoneId: zoneID, RecordId: recordID, }) if zone, ok := m.Records[zoneID]; ok { if _, ok := zone[recordID]; ok { name := zone[recordID].Name delete(zone, recordID) if strings.HasPrefix(name, "newerror-delete-") { return errors.New("failed to delete erroring DNS record") } return nil } } return nil } func (m *mockCloudFlareClient) ZoneIDByName(zoneName string) (string, error) { // Simulate iterator error (line 144) if m.listZonesError != nil { return "", fmt.Errorf("failed to list zones from CloudFlare API: %w", m.listZonesError) } for id, name := range m.Zones { if name == zoneName { return id, nil } } // Use the improved error message (line 147) return "", fmt.Errorf("zone %q not found in CloudFlare account - verify the zone exists and API credentials have access to it", zoneName) } func (m *mockCloudFlareClient) ListZones(_ context.Context, _ zones.ZoneListParams) autoPager[zones.Zone] { if m.listZonesError != nil { return &mockAutoPager[zones.Zone]{ err: m.listZonesError, } } var results []zones.Zone for id, zoneName := range m.Zones { results = append(results, zones.Zone{ ID: id, Name: zoneName, Plan: zones.ZonePlan{IsSubscribed: strings.HasSuffix(zoneName, "bar.com")}, // nolint:SA1019 // Plan.IsSubscribed is deprecated but no replacement available yet }) } return &mockAutoPager[zones.Zone]{ items: results, } } func (m *mockCloudFlareClient) GetZone(_ context.Context, zoneID string) (*zones.Zone, error) { if m.getZoneError != nil { return nil, m.getZoneError } for id, zoneName := range m.Zones { if zoneID == id { return &zones.Zone{ ID: zoneID, Name: zoneName, Plan: zones.ZonePlan{IsSubscribed: strings.HasSuffix(zoneName, "bar.com")}, // nolint:SA1019 // Plan.IsSubscribed is deprecated but no replacement available yet }, nil } } return nil, errors.New("Unknown zoneID: " + zoneID) } func AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, managedRecords []string, args ...any) { t.Helper() var client *mockCloudFlareClient if provider.Client == nil { client = NewMockCloudFlareClient() provider.Client = client } else { client = provider.Client.(*mockCloudFlareClient) } ctx := t.Context() records, err := provider.Records(ctx) if err != nil { t.Fatalf("cannot fetch records, %s", err) } endpoints, err = provider.AdjustEndpoints(endpoints) assert.NoError(t, err) domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) plan := &plan.Plan{ Current: records, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: managedRecords, } changes := plan.Calculate().Changes // Records other than A, CNAME and NS are not supported by planner, just create them for _, endpoint := range endpoints { if !slices.Contains(managedRecords, endpoint.RecordType) { changes.Create = append(changes.Create, endpoint) } } err = provider.ApplyChanges(t.Context(), changes) if err != nil { t.Fatalf("cannot apply changes, %s", err) } td.Cmp(t, client.Actions, actions, args...) } func TestCloudflareA(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "A", DNSName: "bar.com", Targets: endpoint.Targets{"127.0.0.1", "127.0.0.2"}, }, } AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"), Type: "A", Name: "bar.com", Content: "127.0.0.1", TTL: 1, Proxied: false, }, }, { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.2"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "bar.com", "127.0.0.2"), Type: "A", Name: "bar.com", Content: "127.0.0.2", TTL: 1, Proxied: false, }, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, ) } func TestCloudflareCname(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "CNAME", DNSName: "cname.bar.com", Targets: endpoint.Targets{"google.com", "facebook.com"}, }, } AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("CNAME", "cname.bar.com", "google.com"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("CNAME", "cname.bar.com", "google.com"), Type: "CNAME", Name: "cname.bar.com", Content: "google.com", TTL: 1, Proxied: false, }, }, { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("CNAME", "cname.bar.com", "facebook.com"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("CNAME", "cname.bar.com", "facebook.com"), Type: "CNAME", Name: "cname.bar.com", Content: "facebook.com", TTL: 1, Proxied: false, }, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, ) } func TestCloudflareMx(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "MX", DNSName: "mx.bar.com", Targets: endpoint.Targets{"10 google.com", "20 facebook.com"}, }, } AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("MX", "mx.bar.com", "google.com"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("MX", "mx.bar.com", "google.com"), Type: "MX", Name: "mx.bar.com", Content: "google.com", Priority: 10, TTL: 1, Proxied: false, }, }, { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("MX", "mx.bar.com", "facebook.com"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("MX", "mx.bar.com", "facebook.com"), Type: "MX", Name: "mx.bar.com", Content: "facebook.com", Priority: 20, TTL: 1, Proxied: false, }, }, }, []string{endpoint.RecordTypeMX}, ) } func TestCloudflareTxt(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "TXT", DNSName: "txt.bar.com", Targets: endpoint.Targets{"v=spf1 include:_spf.google.com ~all"}, }, } AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("TXT", "txt.bar.com", "v=spf1 include:_spf.google.com ~all"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("TXT", "txt.bar.com", "v=spf1 include:_spf.google.com ~all"), Type: "TXT", Name: "txt.bar.com", Content: "v=spf1 include:_spf.google.com ~all", TTL: 1, Proxied: false, }, }, }, []string{endpoint.RecordTypeTXT}, ) } func TestCloudflareCustomTTL(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "A", DNSName: "ttl.bar.com", Targets: endpoint.Targets{"127.0.0.1"}, RecordTTL: 120, }, } AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "ttl.bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "ttl.bar.com", "127.0.0.1"), Type: "A", Name: "ttl.bar.com", Content: "127.0.0.1", TTL: 120, Proxied: false, }, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, ) } func TestCloudflareProxiedDefault(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "A", DNSName: "bar.com", Targets: endpoint.Targets{"127.0.0.1"}, }, } AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"), Type: "A", Name: "bar.com", Content: "127.0.0.1", TTL: 1, Proxied: true, }, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, ) } func TestCloudflareProxiedOverrideTrue(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "A", DNSName: "bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true", }, }, }, } AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"), Type: "A", Name: "bar.com", Content: "127.0.0.1", TTL: 1, Proxied: true, }, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, ) } func TestCloudflareProxiedOverrideFalse(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "A", DNSName: "bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, }, }, } AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"), Type: "A", Name: "bar.com", Content: "127.0.0.1", TTL: 1, Proxied: false, }, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, ) } func TestCloudflareProxiedOverrideIllegal(t *testing.T) { endpoints := []*endpoint.Endpoint{ { RecordType: "A", DNSName: "bar.com", Targets: endpoint.Targets{"127.0.0.1"}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "asfasdfa", }, }, }, } AssertActions(t, &CloudFlareProvider{proxiedByDefault: true}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "bar.com", "127.0.0.1"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "bar.com", "127.0.0.1"), Type: "A", Name: "bar.com", Content: "127.0.0.1", TTL: 1, Proxied: true, }, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, ) } func TestCloudflareSetProxied(t *testing.T) { testCases := []struct { recordType string domain string proxiable bool }{ {"A", "bar.com", true}, {"CNAME", "bar.com", true}, {"TXT", "bar.com", false}, {"MX", "bar.com", false}, {"NS", "bar.com", false}, {"SPF", "bar.com", false}, {"SRV", "bar.com", false}, {"A", "*.bar.com", true}, {"CNAME", "*.docs.bar.com", true}, } for _, testCase := range testCases { t.Run(fmt.Sprint(testCase), func(t *testing.T) { var targets endpoint.Targets var content string var priority float64 if testCase.recordType == "MX" { targets = endpoint.Targets{"10 mx.example.com"} content = "mx.example.com" priority = 10 } else { targets = endpoint.Targets{"127.0.0.1"} content = "127.0.0.1" } endpoints := []*endpoint.Endpoint{ { RecordType: testCase.recordType, DNSName: testCase.domain, Targets: endpoint.Targets{targets[0]}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true", }, }, }, } expectedID := fmt.Sprintf("%s-%s-%s", testCase.domain, testCase.recordType, content) recordData := dns.RecordResponse{ ID: expectedID, Type: dns.RecordResponseType(testCase.recordType), Name: testCase.domain, Content: content, TTL: 1, Proxied: testCase.proxiable, } if testCase.recordType == "MX" { recordData.Priority = priority } AssertActions(t, &CloudFlareProvider{}, endpoints, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: expectedID, RecordData: recordData, }, }, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS, endpoint.RecordTypeMX}, testCase.recordType+" record on "+testCase.domain) }) } } func TestCloudflareZones(t *testing.T) { provider := &CloudFlareProvider{ Client: NewMockCloudFlareClient(), domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } zones, err := provider.Zones(t.Context()) if err != nil { t.Fatal(err) } assert.Len(t, zones, 1) assert.Equal(t, "bar.com", zones[0].Name) } // test failures on zone lookup func TestCloudflareZonesFailed(t *testing.T) { client := NewMockCloudFlareClient() client.getZoneError = errors.New("zone lookup failed") provider := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{"001"}), } _, err := provider.Zones(t.Context()) if err == nil { t.Errorf("should fail, %s", err) } } func TestCloudFlareZonesWithIDFilter(t *testing.T) { client := NewMockCloudFlareClient() client.listZonesError = errors.New("shouldn't need to list zones when ZoneIDFilter in use") provider := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"bar.com", "foo.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{"001"}), } zones, err := provider.Zones(t.Context()) if err != nil { t.Fatal(err) } // foo.com should *not* be returned as it doesn't match ZoneID filter assert.Len(t, zones, 1) assert.Equal(t, "bar.com", zones[0].Name) } func TestCloudflareListZonesRateLimited(t *testing.T) { // Create a mock client that returns a rate limit error client := NewMockCloudFlareClient() client.listZonesError = newCloudflareError(429) p := &CloudFlareProvider{Client: client} // Call the Zones function _, err := p.Zones(t.Context()) // Assert that a soft error was returned if !errors.Is(err, provider.SoftError) { t.Error("expected a rate limit error") } } func TestCloudflareListZonesRateLimitedStringError(t *testing.T) { // Create a mock client that returns a rate limit error client := NewMockCloudFlareClient() client.listZonesError = errors.New("exceeded available rate limit retries") p := &CloudFlareProvider{Client: client} // Call the Zones function _, err := p.Zones(t.Context()) // Assert that a soft error was returned assert.ErrorIs(t, err, provider.SoftError, "expected a rate limit error") } func TestCloudflareListZoneInternalErrors(t *testing.T) { // Create a mock client that returns a internal server error client := NewMockCloudFlareClient() client.listZonesError = newCloudflareError(500) p := &CloudFlareProvider{Client: client} // Call the Zones function _, err := p.Zones(t.Context()) // Assert that a soft error was returned t.Log(err) if !errors.Is(err, provider.SoftError) { t.Errorf("expected a internal error") } } func TestCloudflareRecords(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": ExampleDomain, }) // Set DNSRecordsPerPage to 1 test the pagination behaviour p := &CloudFlareProvider{ Client: client, DNSRecordsConfig: DNSRecordsConfig{PerPage: 1}, } ctx := t.Context() records, err := p.Records(ctx) if err != nil { t.Errorf("should not fail, %s", err) } assert.Len(t, records, 2) client.dnsRecordsError = errors.New("failed to list dns records") _, err = p.Records(ctx) if err == nil { t.Errorf("expected to fail") } client.dnsRecordsError = nil client.listZonesError = newCloudflareError(429) _, err = p.Records(ctx) // Assert that a soft error was returned if !errors.Is(err, provider.SoftError) { t.Error("expected a rate limit error") } client.listZonesError = newCloudflareError(500) _, err = p.Records(ctx) // Assert that a soft error was returned if !errors.Is(err, provider.SoftError) { t.Error("expected a internal server error") } client.listZonesError = errors.New("failed to list zones") _, err = p.Records(ctx) if err == nil { t.Errorf("expected to fail") } } func TestGetDNSRecordsMapWithPerPage(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": ExampleDomain, }) ctx := t.Context() t.Run("PerPage set to positive value", func(t *testing.T) { provider := &CloudFlareProvider{ Client: client, DNSRecordsConfig: DNSRecordsConfig{PerPage: 100}, } _, err := provider.getDNSRecordsMap(ctx, "001") assert.NoError(t, err) assert.True(t, client.dnsRecordsListParams.PerPage.Present) assert.InEpsilon(t, float64(100), client.dnsRecordsListParams.PerPage.Value, 0.0001) }) t.Run("PerPage not set", func(t *testing.T) { provider := &CloudFlareProvider{ Client: client, DNSRecordsConfig: DNSRecordsConfig{}, } _, err := provider.getDNSRecordsMap(ctx, "001") assert.NoError(t, err) assert.False(t, client.dnsRecordsListParams.PerPage.Present) }) } func TestCloudflareProvider(t *testing.T) { var err error type EnvVar struct { Key string Value string } // unset environment variables to avoid interference with tests testutils.TestHelperEnvSetter(t, map[string]string{ cfAPIEmailEnvKey: "", cfAPIKeyEnvKey: "", cfAPITokenEnvKey: "", }) tokenFile := "/tmp/cf_api_token" if err := os.WriteFile(tokenFile, []byte("abc123def"), 0o644); err != nil { t.Errorf("failed to write token file, %s", err) } testCases := []struct { Name string Environment []EnvVar ShouldFail bool }{ { Name: "use_api_token", Environment: []EnvVar{ {Key: cfAPITokenEnvKey, Value: "abc123def"}, }, ShouldFail: false, }, { Name: "use_api_token_file_contents", Environment: []EnvVar{ {Key: cfAPITokenEnvKey, Value: tokenFile}, }, ShouldFail: false, }, { Name: "use_email_and_key", Environment: []EnvVar{ {Key: cfAPIKeyEnvKey, Value: "xxxxxxxxxxxxxxxxx"}, {Key: cfAPIEmailEnvKey, Value: "test@test.com"}, }, ShouldFail: false, }, { Name: "no_use_email_and_key", Environment: []EnvVar{}, ShouldFail: true, }, { Name: "use_credentials_in_missing_file", Environment: []EnvVar{ {Key: cfAPITokenEnvKey, Value: "file://abc"}, }, ShouldFail: true, }, { Name: "use_credentials_in_missing_file", Environment: []EnvVar{ {Key: cfAPITokenEnvKey, Value: "file:/tmp/cf_api_token"}, }, ShouldFail: false, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { for _, env := range tc.Environment { t.Setenv(env.Key, env.Value) } _, err = newProvider( endpoint.NewDomainFilter([]string{"bar.com"}), provider.NewZoneIDFilter([]string{""}), false, true, RegionalServicesConfig{Enabled: false}, CustomHostnamesConfig{Enabled: false}, DNSRecordsConfig{PerPage: 5000, Comment: ""}, ) if err != nil && !tc.ShouldFail { t.Errorf("should not fail, %s", err) } if err == nil && tc.ShouldFail { t.Errorf("should fail, %s", err) } }) } } func TestCloudflareApplyChanges(t *testing.T) { changes := &plan.Changes{} client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, } changes.Create = []*endpoint.Endpoint{{ DNSName: "new.bar.com", Targets: endpoint.Targets{"target"}, }, { DNSName: "new.ext-dns-test.unrelated.to", Targets: endpoint.Targets{"target"}, }} changes.Delete = []*endpoint.Endpoint{{ DNSName: "foobar.bar.com", Targets: endpoint.Targets{"target"}, }} changes.UpdateOld = []*endpoint.Endpoint{{ DNSName: "foobar.bar.com", Targets: endpoint.Targets{"target-old"}, }} changes.UpdateNew = []*endpoint.Endpoint{{ DNSName: "foobar.bar.com", Targets: endpoint.Targets{"target-new"}, }} err := provider.ApplyChanges(t.Context(), changes) if err != nil { t.Errorf("should not fail, %s", err) } td.Cmp(t, client.Actions, []MockAction{ { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("", "new.bar.com", "target"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("", "new.bar.com", "target"), Name: "new.bar.com", Content: "target", TTL: 1, Proxied: false, }, }, { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("", "foobar.bar.com", "target-new"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("", "foobar.bar.com", "target-new"), Name: "foobar.bar.com", Content: "target-new", TTL: 1, Proxied: false, }, }, }) // empty changes changes.Create = []*endpoint.Endpoint{} changes.Delete = []*endpoint.Endpoint{} changes.UpdateOld = []*endpoint.Endpoint{} changes.UpdateNew = []*endpoint.Endpoint{} err = provider.ApplyChanges(t.Context(), changes) if err != nil { t.Errorf("should not fail, %s", err) } } func TestCloudflareDryRunApplyChanges(t *testing.T) { changes := &plan.Changes{} client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, DryRun: true, } changes.Create = []*endpoint.Endpoint{{ DNSName: "new.bar.com", Targets: endpoint.Targets{"target"}, }} err := provider.ApplyChanges(t.Context(), changes) if err != nil { t.Errorf("should not fail, %s", err) } ctx := t.Context() records, err := provider.Records(ctx) if err != nil { t.Errorf("should not fail, %s", err) } assert.Empty(t, records, "should not have any records") } func TestCloudflareApplyChangesError(t *testing.T) { changes := &plan.Changes{} client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, } changes.Create = []*endpoint.Endpoint{{ DNSName: "newerror.bar.com", Targets: endpoint.Targets{"target"}, }} err := provider.ApplyChanges(t.Context(), changes) if err == nil { t.Errorf("should fail, %s", err) } } func TestCloudflareGetRecordID(t *testing.T) { p := &CloudFlareProvider{} recordsMap := DNSRecordsMap{ {Name: "foo.com", Type: endpoint.RecordTypeCNAME, Content: "foobar"}: { Name: "foo.com", Type: endpoint.RecordTypeCNAME, Content: "foobar", ID: "1", }, {Name: "bar.de", Type: endpoint.RecordTypeA}: { Name: "bar.de", Type: endpoint.RecordTypeA, ID: "2", }, {Name: "bar.de", Type: endpoint.RecordTypeA, Content: "1.2.3.4"}: { Name: "bar.de", Type: endpoint.RecordTypeA, Content: "1.2.3.4", ID: "2", }, } assert.Empty(t, p.getRecordID(recordsMap, dns.RecordResponse{ Name: "foo.com", Type: endpoint.RecordTypeA, Content: "foobar", })) assert.Empty(t, p.getRecordID(recordsMap, dns.RecordResponse{ Name: "foo.com", Type: endpoint.RecordTypeCNAME, Content: "fizfuz", })) assert.Equal(t, "1", p.getRecordID(recordsMap, dns.RecordResponse{ Name: "foo.com", Type: endpoint.RecordTypeCNAME, Content: "foobar", })) assert.Empty(t, p.getRecordID(recordsMap, dns.RecordResponse{ Name: "bar.de", Type: endpoint.RecordTypeA, Content: "2.3.4.5", })) assert.Equal(t, "2", p.getRecordID(recordsMap, dns.RecordResponse{ Name: "bar.de", Type: endpoint.RecordTypeA, Content: "1.2.3.4", })) } func TestCloudflareGroupByNameAndTypeWithCustomHostnames(t *testing.T) { provider := &CloudFlareProvider{ Client: NewMockCloudFlareClient(), domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } testCases := []struct { Name string Records []dns.RecordResponse ExpectedEndpoints []*endpoint.Endpoint }{ { Name: "empty", Records: []dns.RecordResponse{}, ExpectedEndpoints: []*endpoint.Endpoint{}, }, { Name: "single record - single target", Records: []dns.RecordResponse{ { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.1", TTL: defaultTTL, Proxied: false, }, }, ExpectedEndpoints: []*endpoint.Endpoint{ { DNSName: "foo.com", Targets: endpoint.Targets{"10.10.10.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, }, }, }, }, { Name: "single record - multiple targets", Records: []dns.RecordResponse{ { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.1", TTL: defaultTTL, Proxied: false, }, { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.2", TTL: defaultTTL, Proxied: false, }, }, ExpectedEndpoints: []*endpoint.Endpoint{ { DNSName: "foo.com", Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, }, }, }, }, { Name: "multiple record - multiple targets", Records: []dns.RecordResponse{ { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.1", TTL: defaultTTL, Proxied: false, }, { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.2", TTL: defaultTTL, Proxied: false, }, { Name: "bar.de", Type: endpoint.RecordTypeA, Content: "10.10.10.1", TTL: defaultTTL, Proxied: false, }, { Name: "bar.de", Type: endpoint.RecordTypeA, Content: "10.10.10.2", TTL: defaultTTL, Proxied: false, }, }, ExpectedEndpoints: []*endpoint.Endpoint{ { DNSName: "foo.com", Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, }, }, { DNSName: "bar.de", Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, }, }, }, }, { Name: "multiple record - mixed single/multiple targets", Records: []dns.RecordResponse{ { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.1", TTL: defaultTTL, Proxied: false, }, { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.2", TTL: defaultTTL, Proxied: false, }, { Name: "bar.de", Type: endpoint.RecordTypeA, Content: "10.10.10.1", TTL: defaultTTL, Proxied: false, }, }, ExpectedEndpoints: []*endpoint.Endpoint{ { DNSName: "foo.com", Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, }, }, { DNSName: "bar.de", Targets: endpoint.Targets{"10.10.10.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, }, }, }, }, { Name: "unsupported record type", Records: []dns.RecordResponse{ { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.1", TTL: defaultTTL, Proxied: false, }, { Name: "foo.com", Type: endpoint.RecordTypeA, Content: "10.10.10.2", TTL: defaultTTL, Proxied: false, }, { Name: "bar.de", Type: "NOT SUPPORTED", Content: "10.10.10.1", TTL: defaultTTL, Proxied: false, }, }, ExpectedEndpoints: []*endpoint.Endpoint{ { DNSName: "foo.com", Targets: endpoint.Targets{"10.10.10.1", "10.10.10.2"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "false", }, }, }, }, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { records := make(DNSRecordsMap) for _, r := range tc.Records { records[newDNSRecordIndex(r)] = r } endpoints := provider.groupByNameAndTypeWithCustomHostnames(records, customHostnamesMap{}) // Targets order could be random with underlying map for _, ep := range endpoints { slices.Sort(ep.Targets) } for _, ep := range tc.ExpectedEndpoints { slices.Sort(ep.Targets) } assert.ElementsMatch(t, endpoints, tc.ExpectedEndpoints) }) } } func TestGroupByNameAndTypeWithCustomHostnames_MX(t *testing.T) { t.Parallel() client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { { ID: "mx-1", Name: "mx.bar.com", Type: endpoint.RecordTypeMX, TTL: 3600, Content: "mail.bar.com", Priority: 10, }, { ID: "mx-2", Name: "mx.bar.com", Type: endpoint.RecordTypeMX, TTL: 3600, Content: "mail2.bar.com", Priority: 20, }, }, }) provider := &CloudFlareProvider{ Client: client, } ctx := t.Context() chs := customHostnamesMap{} records, err := provider.getDNSRecordsMap(ctx, "001") assert.NoError(t, err) endpoints := provider.groupByNameAndTypeWithCustomHostnames(records, chs) assert.Len(t, endpoints, 1) mxEndpoint := endpoints[0] assert.Equal(t, "mx.bar.com", mxEndpoint.DNSName) assert.Equal(t, endpoint.RecordTypeMX, mxEndpoint.RecordType) assert.ElementsMatch(t, []string{"10 mail.bar.com", "20 mail2.bar.com"}, mxEndpoint.Targets) assert.Equal(t, endpoint.TTL(3600), mxEndpoint.RecordTTL) } func TestProviderPropertiesIdempotency(t *testing.T) { t.Parallel() testCases := []struct { Name string SetupProvider func(*CloudFlareProvider) SetupRecord func(*dns.RecordResponse) CustomHostnames []customHostname RegionKey string ShouldBeUpdated bool PropertyKey string ExpectPropertyPresent bool ExpectPropertyValue string }{ { Name: "No custom properties, ExpectUpdates: false", SetupProvider: func(_ *CloudFlareProvider) {}, SetupRecord: func(_ *dns.RecordResponse) {}, ShouldBeUpdated: false, }, // Proxied tests { Name: "ProxiedByDefault: true, ProxiedRecord: true, ExpectUpdates: false", SetupProvider: func(p *CloudFlareProvider) { p.proxiedByDefault = true }, SetupRecord: func(r *dns.RecordResponse) { r.Proxied = true }, ShouldBeUpdated: false, }, { Name: "ProxiedByDefault: true, ProxiedRecord: false, ExpectUpdates: true", SetupProvider: func(p *CloudFlareProvider) { p.proxiedByDefault = true }, SetupRecord: func(r *dns.RecordResponse) { r.Proxied = false }, ShouldBeUpdated: true, PropertyKey: annotations.CloudflareProxiedKey, ExpectPropertyValue: "true", }, { Name: "ProxiedByDefault: false, ProxiedRecord: true, ExpectUpdates: true", SetupProvider: func(p *CloudFlareProvider) { p.proxiedByDefault = false }, SetupRecord: func(r *dns.RecordResponse) { r.Proxied = true }, ShouldBeUpdated: true, PropertyKey: annotations.CloudflareProxiedKey, ExpectPropertyValue: "false", }, // Comment tests { Name: "DefaultComment: 'foo', RecordComment: 'foo', ExpectUpdates: false", SetupProvider: func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = "foo" }, SetupRecord: func(r *dns.RecordResponse) { r.Comment = "foo" }, ShouldBeUpdated: false, }, { Name: "DefaultComment: '', RecordComment: none, ExpectUpdates: true", SetupProvider: func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = "" }, SetupRecord: func(r *dns.RecordResponse) { r.Comment = "foo" }, ShouldBeUpdated: true, PropertyKey: annotations.CloudflareRecordCommentKey, ExpectPropertyPresent: false, }, { Name: "DefaultComment: 'foo', RecordComment: 'foo', ExpectUpdates: true", SetupProvider: func(p *CloudFlareProvider) { p.DNSRecordsConfig.Comment = "foo" }, SetupRecord: func(r *dns.RecordResponse) { r.Comment = "" }, ShouldBeUpdated: true, PropertyKey: annotations.CloudflareRecordCommentKey, ExpectPropertyValue: "foo", }, // Regional Hostname tests { Name: "DefaultRegionKey: 'us', RecordRegionKey: 'us', ExpectUpdates: false", SetupProvider: func(p *CloudFlareProvider) { p.RegionalServicesConfig.Enabled = true p.RegionalServicesConfig.RegionKey = "us" }, RegionKey: "us", ShouldBeUpdated: false, }, { Name: "DefaultRegionKey: 'us', RecordRegionKey: 'us', ExpectUpdates: false", SetupProvider: func(p *CloudFlareProvider) { p.RegionalServicesConfig.Enabled = true p.RegionalServicesConfig.RegionKey = "us" }, RegionKey: "eu", ShouldBeUpdated: true, PropertyKey: annotations.CloudflareRegionKey, ExpectPropertyValue: "us", }, // Custom Hostname tests // TODO: add tests for custom hostnames when properly supported } for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { t.Parallel() record := dns.RecordResponse{ ID: "1234567890", Name: "foobar.bar.com", Type: endpoint.RecordTypeA, TTL: 120, Content: "1.2.3.4", } if test.SetupRecord != nil { test.SetupRecord(&record) } client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": {record}, }) if len(test.CustomHostnames) > 0 { customHostnames := make([]customHostname, 0, len(test.CustomHostnames)) for _, ch := range test.CustomHostnames { ch.customOriginServer = record.Name customHostnames = append(customHostnames, ch) } client.customHostnames = map[string][]customHostname{ "001": customHostnames, } } if test.RegionKey != "" { client.regionalHostnames = map[string][]regionalHostname{ "001": {{hostname: record.Name, regionKey: test.RegionKey}}, } } provider := &CloudFlareProvider{ Client: client, } if test.SetupProvider != nil { test.SetupProvider(provider) } current, err := provider.Records(t.Context()) if err != nil { t.Errorf("should not fail, %s", err) } assert.Len(t, current, 1) desired := []*endpoint.Endpoint{} for _, c := range current { // Copy all except ProviderSpecific fields desired = append(desired, &endpoint.Endpoint{ DNSName: c.DNSName, Targets: c.Targets, RecordType: c.RecordType, SetIdentifier: c.SetIdentifier, RecordTTL: c.RecordTTL, Labels: c.Labels, }) } desired, err = provider.AdjustEndpoints(desired) assert.NoError(t, err) plan := plan.Plan{ Current: current, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } plan = *plan.Calculate() require.NotNil(t, plan.Changes, "should have plan") assert.Empty(t, plan.Changes.Create, "should not have creates") assert.Empty(t, plan.Changes.Delete, "should not have deletes") if test.ShouldBeUpdated { assert.Len(t, plan.Changes.UpdateOld, 1, "should have old updates") require.Len(t, plan.Changes.UpdateNew, 1, "should have new updates") if test.PropertyKey != "" { value, ok := plan.Changes.UpdateNew[0].GetProviderSpecificProperty(test.PropertyKey) if test.ExpectPropertyPresent || test.ExpectPropertyValue != "" { assert.Truef(t, ok, "should have property %s", test.PropertyKey) assert.Equal(t, test.ExpectPropertyValue, value) } else { assert.Falsef(t, ok, "should not have property %s", test.PropertyKey) } } else { assert.Empty(t, test.ExpectPropertyValue, "test misconfigured, should not expect property value if no property key set") assert.False(t, test.ExpectPropertyPresent, "test misconfigured, should not expect property presence if no property key set") } } else { assert.Empty(t, plan.Changes.UpdateNew, "should not have new updates") assert.Empty(t, plan.Changes.UpdateOld, "should not have old updates") assert.Empty(t, test.PropertyKey, "test misconfigured, should not expect property if no update expected") assert.Empty(t, test.ExpectPropertyValue, "test misconfigured, should not expect property value if no update expected") assert.False(t, test.ExpectPropertyPresent, "test misconfigured, should not expect property presence if no update expected") } }) } } func TestCloudflareComplexUpdate(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": ExampleDomain, }) provider := &CloudFlareProvider{ Client: client, } ctx := t.Context() records, err := provider.Records(ctx) if err != nil { t.Errorf("should not fail, %s", err) } domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) endpoints, err := provider.AdjustEndpoints([]*endpoint.Endpoint{ { DNSName: "foobar.bar.com", Targets: endpoint.Targets{"1.2.3.4", "2.3.4.5"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true", }, }, }, }) assert.NoError(t, err) plan := &plan.Plan{ Current: records, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } planned := plan.Calculate() err = provider.ApplyChanges(t.Context(), planned.Changes) if err != nil { t.Errorf("should not fail, %s", err) } td.CmpDeeply(t, client.Actions, []MockAction{ { Name: "Delete", ZoneId: "001", RecordId: "2345678901", }, { Name: "Update", ZoneId: "001", RecordId: "1234567890", RecordData: dns.RecordResponse{ ID: "1234567890", Name: "foobar.bar.com", Type: "A", Content: "1.2.3.4", TTL: 1, Proxied: true, }, }, { Name: "Create", ZoneId: "001", RecordId: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"), RecordData: dns.RecordResponse{ ID: generateDNSRecordID("A", "foobar.bar.com", "2.3.4.5"), Name: "foobar.bar.com", Type: "A", Content: "2.3.4.5", TTL: 1, Proxied: true, }, }, }) } func TestCustomTTLWithEnabledProxyNotChanged(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { { ID: "1234567890", Name: "foobar.bar.com", Type: endpoint.RecordTypeA, TTL: 1, Content: "1.2.3.4", Proxied: true, }, }, }) provider := &CloudFlareProvider{ Client: client, } records, err := provider.Records(t.Context()) if err != nil { t.Errorf("should not fail, %s", err) } endpoints := []*endpoint.Endpoint{ { DNSName: "foobar.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 300, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true", }, }, }, } provider.AdjustEndpoints(endpoints) domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) plan := &plan.Plan{ Current: records, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } planned := plan.Calculate() assert.Empty(t, planned.Changes.Create, "no new changes should be here") assert.Empty(t, planned.Changes.UpdateNew, "no new changes should be here") assert.Empty(t, planned.Changes.UpdateOld, "no new changes should be here") assert.Empty(t, planned.Changes.Delete, "no new changes should be here") } func TestCloudFlareProvider_Region(t *testing.T) { testutils.TestHelperEnvSetter(t, map[string]string{ cfAPITokenEnvKey: "abc123def", cfAPIEmailEnvKey: "test@test.com", }) provider, err := newProvider( endpoint.NewDomainFilter([]string{"example.com"}), provider.ZoneIDFilter{}, true, false, RegionalServicesConfig{Enabled: false, RegionKey: "us"}, CustomHostnamesConfig{Enabled: false}, DNSRecordsConfig{PerPage: 50, Comment: ""}, ) assert.NoError(t, err, "should not fail to create provider") assert.True(t, provider.RegionalServicesConfig.Enabled, "expect regional services to be enabled") assert.Equal(t, "us", provider.RegionalServicesConfig.RegionKey, "expected region key to be 'us'") } func TestCloudFlareProvider_newCloudFlareChange(t *testing.T) { t.Parallel() comment := string(make([]byte, paidZoneMaxCommentLength+1)) freeValidComment := comment[:freeZoneMaxCommentLength] freeInvalidComment := comment[:freeZoneMaxCommentLength+1] paidValidComment := comment[:paidZoneMaxCommentLength] paidInvalidComment := comment[:paidZoneMaxCommentLength+1] freeProvider := &CloudFlareProvider{ Client: NewMockCloudFlareClient(), domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), RegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}, } paidProvider := &CloudFlareProvider{ Client: NewMockCloudFlareClient(), domainFilter: endpoint.NewDomainFilter([]string{"bar.com"}), RegionalServicesConfig: RegionalServicesConfig{Enabled: true, RegionKey: "us"}, DNSRecordsConfig: DNSRecordsConfig{Comment: paidValidComment}, } ep := &endpoint.Endpoint{ DNSName: "example.com", RecordType: "A", Targets: []string{"192.0.2.1"}, } change, _ := freeProvider.newCloudFlareChange(cloudFlareCreate, ep, ep.Targets[0], nil) if change.RegionalHostname.regionKey != "us" { t.Errorf("expected region key to be 'us', but got '%s'", change.RegionalHostname.regionKey) } commentTestCases := []struct { name string provider *CloudFlareProvider endpoint *endpoint.Endpoint expected int }{ { name: "For free Zone respecting comment length, expect no trimming", provider: freeProvider, endpoint: &endpoint.Endpoint{ DNSName: "example.com", RecordType: "A", Targets: []string{"192.0.2.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: annotations.CloudflareRecordCommentKey, Value: freeValidComment, }, }, }, expected: len(freeValidComment), }, { name: "For free Zones not respecting comment length, expect trimmed comments", provider: freeProvider, endpoint: &endpoint.Endpoint{ DNSName: "example.com", RecordType: "A", Targets: []string{"192.0.2.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: annotations.CloudflareRecordCommentKey, Value: freeInvalidComment, }, }, }, expected: freeZoneMaxCommentLength, }, { name: "For paid Zones respecting comment length, expect no trimming", provider: paidProvider, endpoint: &endpoint.Endpoint{ DNSName: "bar.com", RecordType: "A", Targets: []string{"192.0.2.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: annotations.CloudflareRecordCommentKey, Value: paidValidComment, }, }, }, expected: len(paidValidComment), }, { name: "For paid Zones not respecting comment length, expect trimmed comments", provider: paidProvider, endpoint: &endpoint.Endpoint{ DNSName: "bar.com", RecordType: "A", Targets: []string{"192.0.2.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: annotations.CloudflareRecordCommentKey, Value: paidInvalidComment, }, }, }, expected: paidZoneMaxCommentLength, }, } for _, test := range commentTestCases { t.Run(test.name, func(t *testing.T) { t.Parallel() change, err := test.provider.newCloudFlareChange(cloudFlareCreate, test.endpoint, test.endpoint.Targets[0], nil) assert.NoError(t, err) if len(change.ResourceRecord.Comment) != test.expected { t.Errorf("expected comment to be %d characters long, but got %d", test.expected, len(change.ResourceRecord.Comment)) } }) } } func TestCloudFlareProvider_submitChangesCNAME(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { { ID: "1234567890", Name: "my-domain-here.app", Type: endpoint.RecordTypeCNAME, TTL: 1, Content: "my-tunnel-guid-here.cfargotunnel.com", Proxied: true, }, { ID: "9876543210", Name: "my-domain-here.app", Type: endpoint.RecordTypeTXT, TTL: 1, Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app", }, }, }) // zoneIdFilter := provider.NewZoneIDFilter([]string{"001"}) provider := &CloudFlareProvider{ Client: client, } changes := []*cloudFlareChange{ { Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{ Name: "my-domain-here.app", Type: endpoint.RecordTypeCNAME, ID: "1234567890", Content: "my-tunnel-guid-here.cfargotunnel.com", }, RegionalHostname: regionalHostname{ hostname: "my-domain-here.app", }, }, { Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{ Name: "my-domain-here.app", Type: endpoint.RecordTypeTXT, ID: "9876543210", Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app", }, RegionalHostname: regionalHostname{ hostname: "my-domain-here.app", regionKey: "", }, }, } // Should not return an error err := provider.submitChanges(t.Context(), changes) if err != nil { t.Errorf("should not fail, %s", err) } } func TestCloudFlareProvider_submitChangesApex(t *testing.T) { // Create a mock CloudFlare client with APEX records client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { { ID: "1234567890", Name: "@", // APEX record Type: endpoint.RecordTypeCNAME, TTL: 1, Content: "my-tunnel-guid-here.cfargotunnel.com", Proxied: true, }, { ID: "9876543210", Name: "@", // APEX record Type: endpoint.RecordTypeTXT, TTL: 1, Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app", }, }, }) // Create a CloudFlare provider instance provider := &CloudFlareProvider{ Client: client, } // Define changes to submit changes := []*cloudFlareChange{ { Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{ Name: "@", // APEX record Type: endpoint.RecordTypeCNAME, ID: "1234567890", Content: "my-tunnel-guid-here.cfargotunnel.com", }, RegionalHostname: regionalHostname{ hostname: "@", // APEX record }, }, { Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{ Name: "@", // APEX record Type: endpoint.RecordTypeTXT, ID: "9876543210", Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app", }, RegionalHostname: regionalHostname{ hostname: "@", // APEX record regionKey: "", }, }, } // Submit changes and verify no error is returned err := provider.submitChanges(t.Context(), changes) if err != nil { t.Errorf("should not fail, %s", err) } } func TestCloudflareZoneRecordsFail(t *testing.T) { client := &mockCloudFlareClient{ Zones: map[string]string{ "newerror-001": "bar.com", }, Records: map[string]map[string]dns.RecordResponse{}, customHostnames: map[string][]customHostname{}, } failingProvider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true}, } ctx := t.Context() _, err := failingProvider.Records(ctx) if err == nil { t.Errorf("should fail - invalid zone id, %s", err) } } // TestCloudflareLongRecordsErrorLog checks if the error is logged when a record name exceeds 63 characters // it's not likely to happen in practice, as the Cloudflare API should reject having it func TestCloudflareLongRecordsErrorLog(t *testing.T) { client := NewMockCloudFlareClientWithRecords(map[string][]dns.RecordResponse{ "001": { { ID: "1234567890", Name: "very-very-very-very-very-very-very-long-name-more-than-63-bytes-long.bar.com", Type: endpoint.RecordTypeTXT, TTL: 120, Content: "some-content", }, }, }) hook := logtest.LogsUnderTestWithLogLevel(log.InfoLevel, t) p := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true}, } ctx := t.Context() _, err := p.Records(ctx) if err != nil { t.Errorf("should not fail - too long record, %s", err) } logtest.TestHelperLogContains("s longer than 63 characters. Cannot create endpoint", hook, t) } // check if the error is expected func checkFailed(name string, err error, shouldFail bool) error { if errors.Is(err, nil) && shouldFail { return fmt.Errorf("should fail - %q", name) } if !errors.Is(err, nil) && !shouldFail { return fmt.Errorf("should not fail - %q, %w", name, err) } return nil } func TestCloudflareDNSRecordsOperationsFail(t *testing.T) { client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true}, } ctx := t.Context() domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) testFailCases := []struct { Name string Endpoints []*endpoint.Endpoint ExpectedCustomHostnames map[string]string shouldFail bool }{ { Name: "failing to create dns record", Endpoints: []*endpoint.Endpoint{ { DNSName: "newerror.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, }, }, shouldFail: true, }, { Name: "adding failing to list DNS record", Endpoints: []*endpoint.Endpoint{ { DNSName: "newerror-list-1.foo.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, }, }, shouldFail: false, }, { Name: "causing to list failing to list DNS record", Endpoints: []*endpoint.Endpoint{}, shouldFail: true, }, { Name: "create failing to update DNS record", Endpoints: []*endpoint.Endpoint{ { DNSName: "newerror-update-1.foo.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(defaultTTL), Labels: endpoint.Labels{}, }, }, shouldFail: false, }, { Name: "failing to update DNS record", Endpoints: []*endpoint.Endpoint{ { DNSName: "newerror-update-1.foo.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 1234, Labels: endpoint.Labels{}, }, }, shouldFail: true, }, { Name: "create failing to delete DNS record", Endpoints: []*endpoint.Endpoint{ { DNSName: "newerror-delete-1.foo.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 1234, Labels: endpoint.Labels{}, }, }, shouldFail: false, }, { Name: "failing to delete erroring DNS record", Endpoints: []*endpoint.Endpoint{}, shouldFail: true, }, } for _, tc := range testFailCases { t.Run(tc.Name, func(t *testing.T) { var err error var records, endpoints []*endpoint.Endpoint records, err = provider.Records(ctx) if errors.Is(err, nil) { endpoints, err = provider.AdjustEndpoints(tc.Endpoints) } if errors.Is(err, nil) { plan := &plan.Plan{ Current: records, Desired: endpoints, DomainFilter: endpoint.MatchAllDomainFilters{domainFilter}, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME}, } planned := plan.Calculate() err = provider.ApplyChanges(t.Context(), planned.Changes) } if e := checkFailed(tc.Name, err, tc.shouldFail); !errors.Is(e, nil) { t.Error(e) } }) } } func TestZoneHasPaidPlan(t *testing.T) { client := NewMockCloudFlareClient() cfprovider := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } assert.False(t, cfprovider.ZoneHasPaidPlan("subdomain.foo.com")) assert.True(t, cfprovider.ZoneHasPaidPlan("subdomain.bar.com")) assert.False(t, cfprovider.ZoneHasPaidPlan("invaliddomain")) client.getZoneError = errors.New("zone lookup failed") cfproviderWithZoneError := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } assert.False(t, cfproviderWithZoneError.ZoneHasPaidPlan("subdomain.foo.com")) } func TestCloudflareApplyChanges_AllErrorLogPaths(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.ErrorLevel, t) client := NewMockCloudFlareClient() provider := &CloudFlareProvider{ Client: client, } cases := []struct { name string changes *plan.Changes customHostnamesEnabled bool errorLogCount int }{ { name: "Create error (custom hostnames enabled)", changes: &plan.Changes{ Create: []*endpoint.Endpoint{{ DNSName: "bad-create.bar.com", RecordType: "MX", Targets: endpoint.Targets{"not-a-valid-mx"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "bad-create-custom.bar.com", }, }, }}, }, customHostnamesEnabled: true, errorLogCount: 1, }, { name: "Delete error (custom hostnames enabled)", changes: &plan.Changes{ Delete: []*endpoint.Endpoint{{ DNSName: "bad-delete.bar.com", RecordType: "MX", Targets: endpoint.Targets{"not-a-valid-mx"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "bad-delete-custom.bar.com", }, }, }}, }, customHostnamesEnabled: true, errorLogCount: 1, }, { name: "Update add/remove error (custom hostnames enabled)", changes: &plan.Changes{ UpdateNew: []*endpoint.Endpoint{{ DNSName: "bad-update-add.bar.com", RecordType: "MX", Targets: endpoint.Targets{"not-a-valid-mx"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "bad-update-add-custom.bar.com", }, }, }}, UpdateOld: []*endpoint.Endpoint{{ DNSName: "old-bad-update-add.bar.com", RecordType: "MX", Targets: endpoint.Targets{"not-a-valid-mx-but-still-updated"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "bad-update-add-custom.bar.com", }, }, }}, }, customHostnamesEnabled: true, errorLogCount: 2, }, { name: "Update leave error (custom hostnames enabled)", changes: &plan.Changes{ UpdateOld: []*endpoint.Endpoint{{ DNSName: "bad-update-leave.bar.com", RecordType: "MX", Targets: endpoint.Targets{"not-a-valid-mx"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "bad-update-leave-custom.bar.com", }, }, }}, UpdateNew: []*endpoint.Endpoint{{ DNSName: "bad-update-leave.bar.com", RecordType: "MX", Targets: endpoint.Targets{"not-a-valid-mx"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "bad-update-leave-custom.bar.com", }, }, }}, }, customHostnamesEnabled: true, errorLogCount: 1, }, { name: "Delete error (custom hostnames disabled)", changes: &plan.Changes{ Delete: []*endpoint.Endpoint{{ DNSName: "bad-delete2.bar.com", RecordType: "MX", Targets: endpoint.Targets{"not-a-valid-mx"}, }}, }, customHostnamesEnabled: false, errorLogCount: 1, }, } // Test with custom hostnames enabled and disabled for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if tc.customHostnamesEnabled { provider.CustomHostnamesConfig = CustomHostnamesConfig{Enabled: true} } else { provider.CustomHostnamesConfig = CustomHostnamesConfig{Enabled: false} } hook.Reset() err := provider.ApplyChanges(t.Context(), tc.changes) assert.NoError(t, err, "ApplyChanges should not return error for newCloudFlareChange error (it should log and continue)") errorLogCount := 0 for _, entry := range hook.Entries { if entry.Level == log.ErrorLevel && strings.Contains(entry.Message, "failed to create cloudflare change") { errorLogCount++ } } assert.Equal(t, tc.errorLogCount, errorLogCount, "expected error log count for %s", tc.name) }) } } func TestCloudFlareProvider_SupportedAdditionalRecordTypes(t *testing.T) { provider := &CloudFlareProvider{} tests := []struct { recordType string expected bool }{ {endpoint.RecordTypeMX, true}, {endpoint.RecordTypeA, true}, {endpoint.RecordTypeCNAME, true}, {endpoint.RecordTypeTXT, true}, {endpoint.RecordTypeNS, true}, {"SRV", true}, {"SPF", false}, {"LOC", false}, {"UNKNOWN", false}, } for _, tt := range tests { t.Run(tt.recordType, func(t *testing.T) { result := provider.SupportedAdditionalRecordTypes(tt.recordType) assert.Equal(t, tt.expected, result) }) } } func TestCloudflareZoneChanges(t *testing.T) { client := NewMockCloudFlareClient() cfProvider := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } // Test zone listing and filtering zones, err := cfProvider.Zones(t.Context()) assert.NoError(t, err) assert.Len(t, zones, 2) // Verify zone names zoneNames := make([]string, len(zones)) for i, zone := range zones { zoneNames[i] = zone.Name } assert.Contains(t, zoneNames, "foo.com") assert.Contains(t, zoneNames, "bar.com") // Test zone filtering with specific zone ID providerWithZoneFilter := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{"001"}), } filteredZones, err := providerWithZoneFilter.Zones(t.Context()) assert.NoError(t, err) assert.Len(t, filteredZones, 1) assert.Equal(t, "bar.com", filteredZones[0].Name) // zone 001 is bar.com assert.Equal(t, "001", filteredZones[0].ID) // Test zone changes grouping changes := []*cloudFlareChange{ { Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{Name: "test1.foo.com", Type: "A", Content: "1.2.3.4"}, }, { Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{Name: "test2.foo.com", Type: "A", Content: "1.2.3.5"}, }, { Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{Name: "test1.bar.com", Type: "A", Content: "1.2.3.6"}, }, } changesByZone := cfProvider.changesByZone(zones, changes) assert.Len(t, changesByZone, 2) assert.Len(t, changesByZone["001"], 1) // bar.com zone (test1.bar.com) assert.Len(t, changesByZone["002"], 2) // foo.com zone (test1.foo.com, test2.foo.com) // Test paid plan detection assert.False(t, cfProvider.ZoneHasPaidPlan("subdomain.foo.com")) // free plan assert.True(t, cfProvider.ZoneHasPaidPlan("subdomain.bar.com")) // paid plan } func TestCloudflareZoneErrors(t *testing.T) { client := NewMockCloudFlareClient() // Test list zones error client.listZonesError = errors.New("failed to list zones") cfProvider := &CloudFlareProvider{ Client: client, } zones, err := cfProvider.Zones(t.Context()) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to list zones") assert.Nil(t, zones) // Test get zone error client.listZonesError = nil client.getZoneError = errors.New("failed to get zone") // This should still work for listing but fail when getting individual zones zones, err = cfProvider.Zones(t.Context()) assert.NoError(t, err) // List works, individual gets may fail internally assert.NotNil(t, zones) } func TestCloudflareZoneFiltering(t *testing.T) { client := NewMockCloudFlareClient() // Test with domain filter only cfProvider := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } zones, err := cfProvider.Zones(t.Context()) assert.NoError(t, err) assert.Len(t, zones, 1) assert.Equal(t, "foo.com", zones[0].Name) // Test with zone ID filter providerWithIDFilter := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{}), zoneIDFilter: provider.NewZoneIDFilter([]string{"002"}), } filteredZones, err := providerWithIDFilter.Zones(t.Context()) assert.NoError(t, err) assert.Len(t, filteredZones, 1) assert.Equal(t, "foo.com", filteredZones[0].Name) // zone 002 is foo.com assert.Equal(t, "002", filteredZones[0].ID) } func TestCloudflareZonePlanDetection(t *testing.T) { client := NewMockCloudFlareClient() cfProvider := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } // Test free plan detection (foo.com) assert.False(t, cfProvider.ZoneHasPaidPlan("foo.com")) assert.False(t, cfProvider.ZoneHasPaidPlan("subdomain.foo.com")) assert.False(t, cfProvider.ZoneHasPaidPlan("deep.subdomain.foo.com")) // Test paid plan detection (bar.com) assert.True(t, cfProvider.ZoneHasPaidPlan("bar.com")) assert.True(t, cfProvider.ZoneHasPaidPlan("subdomain.bar.com")) assert.True(t, cfProvider.ZoneHasPaidPlan("deep.subdomain.bar.com")) // Test invalid domain assert.False(t, cfProvider.ZoneHasPaidPlan("invalid.domain.com")) // Test with zone error client.getZoneError = errors.New("zone lookup failed") providerWithError := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } assert.False(t, providerWithError.ZoneHasPaidPlan("subdomain.foo.com")) } func TestCloudflareChangesByZone(t *testing.T) { client := NewMockCloudFlareClient() cfProvider := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } zones, err := cfProvider.Zones(t.Context()) assert.NoError(t, err) assert.Len(t, zones, 2) // Test empty changes emptyChanges := []*cloudFlareChange{} changesByZone := cfProvider.changesByZone(zones, emptyChanges) assert.Len(t, changesByZone, 2) // Should return map with zones but empty slices assert.Empty(t, changesByZone["001"]) // bar.com zone should have no changes assert.Empty(t, changesByZone["002"]) // foo.com zone should have no changes // Test changes for different zones changes := []*cloudFlareChange{ { Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{Name: "api.foo.com", Type: "A", Content: "1.2.3.4"}, }, { Action: cloudFlareUpdate, ResourceRecord: dns.RecordResponse{Name: "www.foo.com", Type: "CNAME", Content: "foo.com"}, }, { Action: cloudFlareCreate, ResourceRecord: dns.RecordResponse{Name: "mail.bar.com", Type: "MX", Content: "10 mail.bar.com"}, }, { Action: cloudFlareDelete, ResourceRecord: dns.RecordResponse{Name: "old.bar.com", Type: "A", Content: "5.6.7.8"}, }, } changesByZone = cfProvider.changesByZone(zones, changes) assert.Len(t, changesByZone, 2) // Verify bar.com zone changes (zone 001) barChanges := changesByZone["001"] assert.Len(t, barChanges, 2) assert.Equal(t, "mail.bar.com", barChanges[0].ResourceRecord.Name) assert.Equal(t, "old.bar.com", barChanges[1].ResourceRecord.Name) // Verify foo.com zone changes (zone 002) fooChanges := changesByZone["002"] assert.Len(t, fooChanges, 2) assert.Equal(t, "api.foo.com", fooChanges[0].ResourceRecord.Name) assert.Equal(t, "www.foo.com", fooChanges[1].ResourceRecord.Name) } func TestConvertCloudflareError(t *testing.T) { tests := []struct { name string inputError error expectSoftError bool description string }{ { name: "Rate limit error via Error type", inputError: newCloudflareError(429), expectSoftError: true, description: "CloudFlare API rate limit error should be converted to soft error", }, { name: "Rate limit error via ClientRateLimited", inputError: newCloudflareError(429), // Complete rate limit error expectSoftError: true, description: "CloudFlare client rate limited error should be converted to soft error", }, { name: "Server error 500", inputError: newCloudflareError(500), expectSoftError: true, description: "Server error (500+) should be converted to soft error", }, { name: "Server error 502", inputError: newCloudflareError(502), expectSoftError: true, description: "Server error (502) should be converted to soft error", }, { name: "Server error 503", inputError: newCloudflareError(503), expectSoftError: true, description: "Server error (503) should be converted to soft error", }, { name: "io.ErrUnexpectedEOF is soft", inputError: io.ErrUnexpectedEOF, expectSoftError: true, description: "Unexpected EOF (connection closed mid-response) should be converted to soft error", }, { name: "io.EOF is soft", inputError: io.EOF, expectSoftError: true, description: "EOF (connection closed before response) should be converted to soft error", }, { name: "wrapped io.ErrUnexpectedEOF is soft", inputError: fmt.Errorf("transport error: %w", io.ErrUnexpectedEOF), expectSoftError: true, description: "Wrapped unexpected EOF should be converted to soft error", }, { name: "Rate limit string error", inputError: errors.New("exceeded available rate limit retries"), expectSoftError: true, description: "String error containing rate limit message should be converted to soft error", }, { name: "Rate limit string error mixed case", inputError: errors.New("request failed: exceeded available rate limit retries for this operation"), expectSoftError: true, description: "String error containing rate limit message should be converted to soft error regardless of context", }, { name: "Client error 400", inputError: newCloudflareError(400), expectSoftError: false, description: "Client error (400) should not be converted to soft error", }, { name: "Client error 401", inputError: newCloudflareError(401), expectSoftError: false, description: "Client error (401) should not be converted to soft error", }, { name: "Client error 404", inputError: newCloudflareError(404), expectSoftError: false, description: "Client error (404) should not be converted to soft error", }, { name: "Generic error", inputError: errors.New("some generic error"), expectSoftError: false, description: "Generic error should not be converted to soft error", }, { name: "Network error", inputError: errors.New("connection refused"), expectSoftError: false, description: "Network error should not be converted to soft error", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := convertCloudflareError(tt.inputError) if tt.expectSoftError { assert.ErrorIs(t, result, provider.SoftError, "Expected soft error for %s: %s", tt.name, tt.description) // Verify error message preservation for all errors now that newCloudflareError // properly initializes the Request/Response fields assert.Contains(t, result.Error(), tt.inputError.Error(), "Original error message should be preserved") } else { assert.NotErrorIs(t, result, provider.SoftError, "Expected non-soft error for %s: %s", tt.name, tt.description) assert.Equal(t, tt.inputError, result, "Non-soft errors should be returned unchanged") } }) } } func TestConvertCloudflareErrorInContext(t *testing.T) { tests := []struct { name string setupMock func(*mockCloudFlareClient) function func(*CloudFlareProvider) error expectSoftError bool description string }{ { name: "Zones with GetZone rate limit error", setupMock: func(client *mockCloudFlareClient) { client.Zones = map[string]string{"zone1": "example.com"} client.getZoneError = newCloudflareError(429) }, function: func(p *CloudFlareProvider) error { p.zoneIDFilter.ZoneIDs = []string{"zone1"} _, err := p.Zones(t.Context()) return err }, expectSoftError: true, description: "Zones function should convert GetZone rate limit errors to soft errors", }, { name: "Zones with GetZone server error", setupMock: func(client *mockCloudFlareClient) { client.Zones = map[string]string{"zone1": "example.com"} client.getZoneError = newCloudflareError(500) }, function: func(p *CloudFlareProvider) error { p.zoneIDFilter.ZoneIDs = []string{"zone1"} _, err := p.Zones(t.Context()) return err }, expectSoftError: true, description: "Zones function should convert GetZone server errors to soft errors", }, { name: "Zones with GetZone client error", setupMock: func(client *mockCloudFlareClient) { client.Zones = map[string]string{"zone1": "example.com"} client.getZoneError = newCloudflareError(404) }, function: func(p *CloudFlareProvider) error { p.zoneIDFilter.ZoneIDs = []string{"zone1"} _, err := p.Zones(t.Context()) return err }, expectSoftError: false, description: "Zones function should not convert GetZone client errors to soft errors", }, { name: "Zones with ListZones rate limit error", setupMock: func(client *mockCloudFlareClient) { client.listZonesError = errors.New("exceeded available rate limit retries") }, function: func(p *CloudFlareProvider) error { _, err := p.Zones(t.Context()) return err }, expectSoftError: true, description: "Zones function should convert ListZones rate limit string errors to soft errors", }, { name: "Zones with ListZones server error", setupMock: func(client *mockCloudFlareClient) { client.listZonesError = newCloudflareError(503) }, function: func(p *CloudFlareProvider) error { _, err := p.Zones(t.Context()) return err }, expectSoftError: true, description: "Zones function should convert ListZones server errors to soft errors", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := NewMockCloudFlareClient() tt.setupMock(client) p := &CloudFlareProvider{ Client: client, zoneIDFilter: provider.ZoneIDFilter{}, } err := tt.function(p) assert.Error(t, err, "Expected an error from %s", tt.name) if tt.expectSoftError { assert.ErrorIs(t, err, provider.SoftError, "Expected soft error for %s: %s", tt.name, tt.description) } else { assert.NotErrorIs(t, err, provider.SoftError, "Expected non-soft error for %s: %s", tt.name, tt.description) } }) } } func TestCloudFlareZonesDomainFilter(t *testing.T) { // Create a domain filter that only matches "bar.com" // This should filter out "foo.com" and trigger the debug log domainFilter := endpoint.NewDomainFilter([]string{"bar.com"}) p := &CloudFlareProvider{ Client: NewMockCloudFlareClient(), domainFilter: domainFilter, } // Capture debug logs to verify the filter log message hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) // Call Zones() which should trigger the domain filter logic zones, err := p.Zones(t.Context()) require.NoError(t, err) // Should only return the "bar.com" zone since "foo.com" is filtered out assert.Len(t, zones, 1) assert.Equal(t, "bar.com", zones[0].Name) assert.Equal(t, "001", zones[0].ID) // Verify that the debug log was written for the filtered zone logtest.TestHelperLogContains("zone \"foo.com\" not in domain filter", hook, t) logtest.TestHelperLogContains("no zoneIDFilter configured, looking at all zones", hook, t) } func TestZoneIDByNameIteratorError(t *testing.T) { client := NewMockCloudFlareClient() // Set up an error that will be returned by the ListZones iterator (line 144) client.listZonesError = fmt.Errorf("CloudFlare API connection timeout") // Call ZoneIDByName which should hit line 144 (iterator error handling) zoneID, err := client.ZoneIDByName("example.com") // Should return empty zone ID and the wrapped iterator error assert.Empty(t, zoneID) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to list zones from CloudFlare API") assert.Contains(t, err.Error(), "CloudFlare API connection timeout") } func TestZoneIDByNameZoneNotFound(t *testing.T) { client := NewMockCloudFlareClient() // Set up mock to return different zones but not the one we're looking for client.Zones = map[string]string{ "zone456": "different.com", "zone789": "another.com", } // Call ZoneIDByName for a zone that doesn't exist, should hit line 147 (zone not found) zoneID, err := client.ZoneIDByName("nonexistent.com") // Should return empty zone ID and the improved error message assert.Empty(t, zoneID) assert.Error(t, err) assert.Contains(t, err.Error(), `zone "nonexistent.com" not found in CloudFlare account`) assert.Contains(t, err.Error(), "verify the zone exists and API credentials have access to it") } func TestGetUpdateDNSRecordParam(t *testing.T) { cfc := cloudFlareChange{ ResourceRecord: dns.RecordResponse{ ID: "1234", Name: "example.com", Type: endpoint.RecordTypeA, TTL: 120, Proxied: true, Content: "1.2.3.4", Priority: 10, Comment: "test-comment", }, } params := getUpdateDNSRecordParam("zone-123", cfc) body := params.Body.(dns.RecordUpdateParamsBody) assert.Equal(t, "zone-123", params.ZoneID.Value) assert.Equal(t, "example.com", body.Name.Value) assert.InDelta(t, 120, float64(body.TTL.Value), 0) assert.True(t, body.Proxied.Value) assert.EqualValues(t, "A", body.Type.Value) assert.Equal(t, "1.2.3.4", body.Content.Value) assert.InDelta(t, 10, float64(body.Priority.Value), 0) assert.Equal(t, "test-comment", body.Comment.Value) } func TestZoneService(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(t.Context()) cancel() client := &zoneService{ service: cloudflare.NewClient(), } zoneID := "foo" t.Run("ListDNSRecord", func(t *testing.T) { t.Parallel() iter := client.ListDNSRecords(ctx, dns.RecordListParams{ZoneID: cloudflare.F("foo")}) assert.False(t, iter.Next()) assert.Empty(t, iter.Current()) assert.ErrorIs(t, iter.Err(), context.Canceled) }) t.Run("CreateDNSRecord", func(t *testing.T) { t.Parallel() params := getCreateDNSRecordParam(zoneID, &cloudFlareChange{}) record, err := client.CreateDNSRecord(ctx, params) assert.Empty(t, record) assert.ErrorIs(t, err, context.Canceled) }) t.Run("UpdateDNSRecord", func(t *testing.T) { t.Parallel() recordParam := getUpdateDNSRecordParam(zoneID, cloudFlareChange{}) _, err := client.UpdateDNSRecord(ctx, "1234", recordParam) assert.ErrorIs(t, err, context.Canceled) }) t.Run("DeleteDNSRecord", func(t *testing.T) { t.Parallel() err := client.DeleteDNSRecord(ctx, "1234", dns.RecordDeleteParams{ZoneID: cloudflare.F("foo")}) assert.ErrorIs(t, err, context.Canceled) }) t.Run("ListZones", func(t *testing.T) { t.Parallel() iter := client.ListZones(ctx, listZonesV4Params()) assert.False(t, iter.Next()) assert.Empty(t, iter.Current()) assert.ErrorIs(t, iter.Err(), context.Canceled) }) t.Run("GetZone", func(t *testing.T) { t.Parallel() zone, err := client.GetZone(ctx, zoneID) assert.Nil(t, zone) assert.ErrorIs(t, err, context.Canceled) }) t.Run("ListDataLocalizationRegionalHostnames", func(t *testing.T) { t.Parallel() params := listDataLocalizationRegionalHostnamesParams(zoneID) iter := client.ListDataLocalizationRegionalHostnames(ctx, params) assert.False(t, iter.Next()) assert.Empty(t, iter.Current()) assert.ErrorIs(t, iter.Err(), context.Canceled) }) t.Run("CreateDataLocalizationRegionalHostname", func(t *testing.T) { t.Parallel() params := createDataLocalizationRegionalHostnameParams(zoneID, regionalHostnameChange{}) err := client.CreateDataLocalizationRegionalHostname(ctx, params) assert.ErrorIs(t, err, context.Canceled) }) t.Run("DeleteDataLocalizationRegionalHostname", func(t *testing.T) { t.Parallel() params := deleteDataLocalizationRegionalHostnameParams(zoneID) err := client.DeleteDataLocalizationRegionalHostname(ctx, "foo", params) assert.ErrorIs(t, err, context.Canceled) }) t.Run("UpdateDataLocalizationRegionalHostname", func(t *testing.T) { t.Parallel() params := updateDataLocalizationRegionalHostnameParams(zoneID, regionalHostnameChange{}) err := client.UpdateDataLocalizationRegionalHostname(ctx, "foo", params) assert.ErrorIs(t, err, context.Canceled) }) t.Run("CustomHostnames", func(t *testing.T) { t.Parallel() iter := client.CustomHostnames(ctx, zoneID) assert.False(t, iter.Next()) assert.Empty(t, iter.Current()) assert.ErrorIs(t, iter.Err(), context.Canceled) }) t.Run("CreateCustomHostname", func(t *testing.T) { t.Parallel() err := client.CreateCustomHostname(ctx, zoneID, customHostname{}) assert.ErrorIs(t, err, context.Canceled) }) t.Run("BatchDNSRecords", func(t *testing.T) { t.Parallel() _, err := client.BatchDNSRecords(ctx, dns.RecordBatchParams{ZoneID: cloudflare.F(zoneID)}) assert.ErrorIs(t, err, context.Canceled) }) } func TestSubmitChanges_ErrorPaths(t *testing.T) { t.Run("getDNSRecordsMap error returns error from submitChanges", func(t *testing.T) { client := NewMockCloudFlareClient() client.dnsRecordsError = errors.New("dns list failed") p := &CloudFlareProvider{Client: client} changes := &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "test.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"}, }, } err := p.ApplyChanges(t.Context(), changes) require.Error(t, err) assert.Contains(t, err.Error(), "could not fetch records from zone") }) t.Run("listCustomHostnamesWithPagination error returns error from submitChanges", func(t *testing.T) { // The mock returns an error for CustomHostnames() when zoneID starts with "newerror-". // CustomHostnamesConfig.Enabled must be true to reach that code path. client := &mockCloudFlareClient{ Zones: map[string]string{ "newerror-zone1": "errorcf.com", }, Records: map[string]map[string]dns.RecordResponse{ "newerror-zone1": {}, }, customHostnames: map[string][]customHostname{}, regionalHostnames: map[string][]regionalHostname{}, } p := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"errorcf.com"}), CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true}, } changes := &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "sub.errorcf.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"}, }, } err := p.ApplyChanges(t.Context(), changes) require.Error(t, err) assert.Contains(t, err.Error(), "could not fetch custom hostnames from zone") }) t.Run("processCustomHostnameChanges failure sets failedChange", func(t *testing.T) { // The mock's CreateCustomHostname fails for "newerror-create.foo.fancybar.com". // With CustomHostnames enabled, the failing create causes processCustomHostnameChanges // to return true, which sets failedChange=true for the zone. client := NewMockCloudFlareClient() p := &CloudFlareProvider{ Client: client, CustomHostnamesConfig: CustomHostnamesConfig{Enabled: true}, } changes := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "a.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A", ProviderSpecific: endpoint.ProviderSpecific{ { Name: "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname", Value: "newerror-create.foo.fancybar.com", }, }, }, }, } err := p.ApplyChanges(t.Context(), changes) require.Error(t, err, "failing custom hostname create should cause an error") }) t.Run("Zones error propagates from submitChanges", func(t *testing.T) { // Setting listZonesError causes p.Zones() to fail inside submitChanges, // exercising the `if err != nil { return err }` block at the top of the loop. client := NewMockCloudFlareClient() client.listZonesError = errors.New("zones fetch failed") p := &CloudFlareProvider{Client: client} changes := &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "test.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: "A"}, }, } err := p.ApplyChanges(t.Context(), changes) require.Error(t, err) assert.Contains(t, err.Error(), "zones fetch failed") }) } func TestParseTagsAnnotation(t *testing.T) { t.Run("parses comma-separated tags", func(t *testing.T) { tags := parseTagsAnnotation("tag1,tag2,tag3") assert.Equal(t, []string{"tag1", "tag2", "tag3"}, tags) }) t.Run("trims whitespace from each tag", func(t *testing.T) { tags := parseTagsAnnotation(" z-tag , a-tag ") assert.Equal(t, []string{"a-tag", "z-tag"}, tags) }) t.Run("sorts tags canonically", func(t *testing.T) { tags := parseTagsAnnotation("c,a,b") assert.Equal(t, []string{"a", "b", "c"}, tags) }) t.Run("skips empty tokens", func(t *testing.T) { tags := parseTagsAnnotation("tag1,,,, tag2") assert.Equal(t, []string{"tag1", "tag2"}, tags) }) } func TestAdjustEndpoints_TagsAnnotation(t *testing.T) { // parseTagsAnnotation is only invoked when the CloudflareTagsKey annotation // is present on the endpoint. This test exercises that branch via AdjustEndpoints. p := &CloudFlareProvider{} ep := &endpoint.Endpoint{ RecordType: "A", DNSName: "test.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: annotations.CloudflareTagsKey, Value: "beta, alpha, gamma", }, }, } adjusted, err := p.AdjustEndpoints([]*endpoint.Endpoint{ep}) require.NoError(t, err) require.Len(t, adjusted, 1) val, ok := adjusted[0].GetProviderSpecificProperty(annotations.CloudflareTagsKey) require.True(t, ok, "tags annotation should still be present after AdjustEndpoints") // Tags should be sorted and whitespace-trimmed assert.Equal(t, "alpha,beta,gamma", val) } func TestZoneServiceZoneIDByName(t *testing.T) { // Build a minimal cloudflare API response page for /zones. writeZonesPage := func(w http.ResponseWriter, zones []map[string]any) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]any{ "result": zones, "result_info": map[string]any{ "count": len(zones), "total_count": len(zones), "page": 1, "per_page": 20, }, "success": true, "errors": []any{}, "messages": []any{}, }); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } t.Run("zone found returns its ID", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { writeZonesPage(w, []map[string]any{ {"id": "zone-abc", "name": "example.com", "plan": map[string]any{"is_subscribed": false}}, }) })) defer ts.Close() svc := &zoneService{service: cloudflare.NewClient( option.WithBaseURL(ts.URL+"/"), option.WithAPIToken("test-token"), option.WithMaxRetries(0), )} id, err := svc.ZoneIDByName("example.com") require.NoError(t, err) assert.Equal(t, "zone-abc", id) }) t.Run("zone not found returns descriptive error", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { writeZonesPage(w, []map[string]any{}) })) defer ts.Close() svc := &zoneService{service: cloudflare.NewClient( option.WithBaseURL(ts.URL+"/"), option.WithAPIToken("test-token"), option.WithMaxRetries(0), )} id, err := svc.ZoneIDByName("missing.com") assert.Empty(t, id) require.Error(t, err) assert.Contains(t, err.Error(), "not found in CloudFlare account") }) t.Run("server error causes wrapped iterator error", func(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) _ = json.NewEncoder(w).Encode(map[string]any{ "result": nil, "success": false, "errors": []map[string]any{{"code": 500, "message": "internal server error"}}, "messages": []any{}, }) })) defer ts.Close() svc := &zoneService{service: cloudflare.NewClient( option.WithBaseURL(ts.URL+"/"), option.WithAPIToken("test-token"), option.WithMaxRetries(0), )} id, err := svc.ZoneIDByName("any.com") assert.Empty(t, id) require.Error(t, err) assert.Contains(t, err.Error(), "failed to list zones from CloudFlare API") }) } ================================================ FILE: provider/cloudflare/pagination.go ================================================ /* 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 cloudflare type autoPager[T any] interface { Next() bool Current() T Err() error } // autoPagerIterator returns an iterator over an autoPager. func autoPagerIterator[T any](iter autoPager[T]) func(yield func(T) bool) { return func(yield func(T) bool) { for iter.Next() { if !yield(iter.Current()) { return } } } } ================================================ FILE: provider/cloudflare/pagination_test.go ================================================ /* 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 cloudflare import ( "errors" "slices" "testing" "github.com/stretchr/testify/assert" ) type mockAutoPager[T any] struct { items []T index int err error errIndex int } func (m *mockAutoPager[T]) Next() bool { m.index++ return !m.hasError() && m.hasNext() } func (m *mockAutoPager[T]) Current() T { if m.hasNext() && !m.hasError() { return m.items[m.index-1] } var zero T return zero } func (m *mockAutoPager[T]) Err() error { return m.err } func (m *mockAutoPager[T]) hasError() bool { return m.err != nil && m.errIndex <= m.index } func (m *mockAutoPager[T]) hasNext() bool { return m.index > 0 && m.index <= len(m.items) } func TestAutoPagerIterator(t *testing.T) { t.Run("iterate empty", func(t *testing.T) { pager := &mockAutoPager[string]{} iterator := autoPagerIterator(pager) collected := slices.Collect(iterator) assert.Empty(t, collected) }) t.Run("iterate all items", func(t *testing.T) { pager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}} iterator := autoPagerIterator(pager) collected := slices.Collect(iterator) assert.Equal(t, []int{1, 2, 3, 4, 5}, collected) }) t.Run("iterate with early termination", func(t *testing.T) { pager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}} iterator := autoPagerIterator(pager) var collected []int for item := range iterator { collected = append(collected, item) if item == 3 { break } } assert.Equal(t, []int{1, 2, 3}, collected) }) t.Run("iterate with error at index", func(t *testing.T) { expectedErr := errors.New("pager error") pager := &mockAutoPager[int]{items: []int{1, 2, 3, 4, 5}, err: expectedErr, errIndex: 3} iterator := autoPagerIterator(pager) collected := slices.Collect(iterator) assert.Equal(t, []int{1, 2}, collected) }) } ================================================ FILE: provider/coredns/OWNERS ================================================ approvers: - ytsarev ================================================ FILE: provider/coredns/coredns.go ================================================ /* Copyright 2017 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 coredns import ( "context" "encoding/json" "errors" "fmt" "math/rand" "net" "os" "slices" "strings" "time" log "github.com/sirupsen/logrus" "go.etcd.io/etcd/api/v3/mvccpb" etcdcv3 "go.etcd.io/etcd/client/v3" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/pkg/tlsutils" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( priority = 10 // default priority when nothing is set etcdTimeout = 5 * time.Second randomPrefixLabel = "prefix" providerSpecificGroup = "coredns/group" ) var ( // avoids allocating a new slice on every call skipLabels = []string{"originalText", "prefix", "resource"} ) // coreDNSClient is an interface to work with CoreDNS service records in etcd type coreDNSClient interface { GetServices(ctx context.Context, prefix string) ([]*Service, error) SaveService(ctx context.Context, value *Service) error DeleteService(ctx context.Context, key string) error } type coreDNSProvider struct { provider.BaseProvider dryRun bool strictlyOwned bool coreDNSPrefix string domainFilter *endpoint.DomainFilter client coreDNSClient } // Service represents CoreDNS etcd record type Service struct { Host string `json:"host,omitempty"` Port int `json:"port,omitempty"` Priority int `json:"priority,omitempty"` Weight int `json:"weight,omitempty"` Text string `json:"text,omitempty"` Mail bool `json:"mail,omitempty"` // Be an MX record. Priority becomes Preference. TTL uint32 `json:"ttl,omitempty"` // When a SRV record with a "Host: IP-address" is added, we synthesize // a srv.Target domain name. Normally we convert the full Key where // the record lives to a DNS name and use this as the srv.Target. When // TargetStrip > 0 we strip the left most TargetStrip labels from the // DNS name. TargetStrip int `json:"targetstrip,omitempty"` // Group is used to group (or *not* to group) different services // together. Services with an identical Group are returned in the same // answer. Group string `json:"group,omitempty"` // Etcd key where we found this service and ignored from json un-/marshaling Key string `json:"-"` // Owner is used to prevent service to be added by different external-dns (only used by external-dns) Owner string `json:"owner,omitempty"` } type etcdClient struct { client *etcdcv3.Client owner string strictlyOwned bool } var _ coreDNSClient = etcdClient{} // GetServices GetService return all Service records stored in etcd stored anywhere under the given key (recursively) func (c etcdClient) GetServices(ctx context.Context, prefix string) ([]*Service, error) { ctx, cancel := context.WithTimeout(ctx, etcdTimeout) defer cancel() path := prefix r, err := c.client.Get(ctx, path, etcdcv3.WithPrefix()) if err != nil { return nil, err } var svcs []*Service bx := make(map[Service]bool) for _, n := range r.Kvs { svc, err := c.unmarshalService(n) if err != nil { return nil, err } if c.strictlyOwned && svc.Owner != c.owner { continue } b := Service{ Host: svc.Host, Port: svc.Port, Priority: svc.Priority, Weight: svc.Weight, Text: svc.Text, Key: string(n.Key), } if _, ok := bx[b]; ok { // skip the service if already added to service list. // the same service might be found in multiple etcd nodes. continue } bx[b] = true svc.Key = string(n.Key) if svc.Priority == 0 { svc.Priority = priority } svcs = append(svcs, svc) } return svcs, nil } // SaveService persists service data into etcd func (c etcdClient) SaveService(ctx context.Context, service *Service) error { ctx, cancel := context.WithTimeout(ctx, etcdTimeout) defer cancel() // check only for empty OwnedBy if c.strictlyOwned && service.Owner != c.owner { r, err := c.client.Get(ctx, service.Key) if err != nil { return fmt.Errorf("etcd get %q: %w", service.Key, err) } // Key missing -> treat as owned (safe to create) if r != nil && len(r.Kvs) != 0 { svc, err := c.unmarshalService(r.Kvs[0]) if err != nil { return fmt.Errorf("failed to unmarshal value for key %q: %w", service.Key, err) } if svc.Owner != c.owner { return fmt.Errorf("key %q is not owned by this provider", service.Key) } } service.Owner = c.owner } value, err := json.Marshal(&service) if err != nil { return err } _, err = c.client.Put(ctx, service.Key, string(value)) if err != nil { return err } return nil } // DeleteService deletes service record from etcd func (c etcdClient) DeleteService(ctx context.Context, key string) error { ctx, cancel := context.WithTimeout(ctx, etcdTimeout) defer cancel() if c.strictlyOwned { rs, err := c.client.Get(ctx, key, etcdcv3.WithPrefix()) if err != nil { return err } for _, r := range rs.Kvs { svc, err := c.unmarshalService(r) if err != nil { return err } if svc.Owner != c.owner { continue } _, err = c.client.Delete(ctx, string(r.Key)) if err != nil { return err } } return err } else { _, err := c.client.Delete(ctx, key, etcdcv3.WithPrefix()) return err } } func (c etcdClient) unmarshalService(n *mvccpb.KeyValue) (*Service, error) { svc := new(Service) if err := json.Unmarshal(n.Value, svc); err != nil { return nil, fmt.Errorf("failed to unmarshal %q: %w", n.Key, err) } return svc, nil } // builds etcd client config depending on connection scheme and TLS parameters func getETCDConfig() (*etcdcv3.Config, error) { etcdURLsStr := os.Getenv("ETCD_URLS") if etcdURLsStr == "" { etcdURLsStr = "http://localhost:2379" } etcdURLs := strings.Split(etcdURLsStr, ",") firstURL := strings.ToLower(etcdURLs[0]) etcdUsername := os.Getenv("ETCD_USERNAME") etcdPassword := os.Getenv("ETCD_PASSWORD") switch { case strings.HasPrefix(firstURL, "http://"): return &etcdcv3.Config{Endpoints: etcdURLs, Username: etcdUsername, Password: etcdPassword}, nil case strings.HasPrefix(firstURL, "https://"): tlsConfig, err := tlsutils.CreateTLSConfig("ETCD") if err != nil { return nil, err } log.Debug("using TLS for etcd") return &etcdcv3.Config{ Endpoints: etcdURLs, TLS: tlsConfig, Username: etcdUsername, Password: etcdPassword, }, nil default: return nil, errors.New("etcd URLs must start with either http:// or https://") } } // the newETCDClient is an etcd client constructor func newETCDClient(owner string, strictlyOwned bool) (coreDNSClient, error) { cfg, err := getETCDConfig() if err != nil { return nil, err } c, err := etcdcv3.New(*cfg) if err != nil { return nil, err } return etcdClient{c, owner, strictlyOwned}, nil } // New creates a CoreDNS/SkyDNS provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(domainFilter, cfg.CoreDNSPrefix, cfg.TXTOwnerID, cfg.CoreDNSStrictlyOwned, cfg.DryRun) } // newProvider is a CoreDNS provider constructor func newProvider(domainFilter *endpoint.DomainFilter, prefix, owner string, strictlyOwned, dryRun bool) (provider.Provider, error) { client, err := newETCDClient(owner, strictlyOwned) if err != nil { return nil, err } return coreDNSProvider{ client: client, dryRun: dryRun, strictlyOwned: strictlyOwned, coreDNSPrefix: prefix, domainFilter: domainFilter, }, nil } // findEp takes an Endpoint slice and looks for an element in it. If found it will // return Endpoint, otherwise it will return nil and a bool of false. func findEp(slice []*endpoint.Endpoint, dnsName string) (*endpoint.Endpoint, bool) { for _, item := range slice { if item.DNSName == dnsName { return item, true } } return nil, false } // Records returns all DNS records found in CoreDNS etcd backend. Depending on the record fields // it may be mapped to one or two records of type A, CNAME, TXT, A+TXT, CNAME+TXT func (p coreDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { var result []*endpoint.Endpoint services, err := p.client.GetServices(ctx, p.coreDNSPrefix) if err != nil { return nil, err } for _, service := range services { domains := strings.Split(strings.TrimPrefix(service.Key, p.coreDNSPrefix), "/") reverse(domains) dnsName := strings.Join(domains[service.TargetStrip:], ".") if !p.domainFilter.Match(dnsName) { continue } log.Debugf("Getting service (%v) with service host (%s)", service, service.Host) prefix := strings.Join(domains[:service.TargetStrip], ".") if service.Host != "" { ep, found := findEp(result, dnsName) if found { ep.Targets = append(ep.Targets, service.Host) log.Debugf("Extending ep (%s) with new service host (%s)", ep, service.Host) } else { ep = endpoint.NewEndpointWithTTL( dnsName, guessRecordType(service.Host), endpoint.TTL(service.TTL), service.Host, ) if service.Group != "" { ep.WithProviderSpecific(providerSpecificGroup, service.Group) } log.Debugf("Creating new ep (%s) with new service host (%s)", ep, service.Host) } if p.strictlyOwned { ep.Labels[endpoint.OwnerLabelKey] = service.Owner } ep.Labels["originalText"] = service.Text ep.Labels[randomPrefixLabel] = prefix ep.Labels[service.Host] = prefix result = append(result, ep) } if service.Text != "" { ep := endpoint.NewEndpoint( dnsName, endpoint.RecordTypeTXT, service.Text, ) if p.strictlyOwned { ep.Labels[endpoint.OwnerLabelKey] = service.Owner } ep.Labels[randomPrefixLabel] = prefix result = append(result, ep) } } return result, nil } func (p coreDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { grouped := p.groupEndpoints(changes) for dnsName, group := range grouped { if !p.domainFilter.Match(dnsName) { log.Debugf("Skipping record %q due to domain filter", dnsName) continue } if err := p.applyGroup(ctx, dnsName, group); err != nil { return err } } return p.deleteEndpoints(ctx, changes.Delete) } func (p coreDNSProvider) groupEndpoints(changes *plan.Changes) map[string][]*endpoint.Endpoint { grouped := make(map[string][]*endpoint.Endpoint) for _, ep := range changes.Create { grouped[ep.DNSName] = append(grouped[ep.DNSName], ep) } for i, ep := range changes.UpdateNew { log.Debugf("Updating labels (%s) with old labels (%s)", ep.Labels, changes.UpdateOld[i].Labels) ep.Labels = changes.UpdateOld[i].Labels grouped[ep.DNSName] = append(grouped[ep.DNSName], ep) } return grouped } func (p coreDNSProvider) applyGroup(ctx context.Context, dnsName string, group []*endpoint.Endpoint) error { var services []*Service for _, ep := range group { if ep.RecordType != endpoint.RecordTypeTXT { srvs, err := p.createServicesForEndpoint(ctx, dnsName, ep) if err != nil { return err } services = append(services, srvs...) } } services = p.updateTXTRecords(dnsName, group, services) for _, service := range services { log.Infof("Add/set key %s to Host=%s, Text=%s, TTL=%d", service.Key, service.Host, service.Text, service.TTL) if p.dryRun { continue } if err := p.client.SaveService(ctx, service); err != nil { return err } } return nil } func (p coreDNSProvider) createServicesForEndpoint(ctx context.Context, dnsName string, ep *endpoint.Endpoint) ([]*Service, error) { var services []*Service for _, target := range ep.Targets { prefix := ep.Labels[target] if prefix == "" { prefix = fmt.Sprintf("%08x", rand.Int31()) log.Infof("Generating new prefix: (%s)", prefix) } group := "" if prop, ok := ep.GetProviderSpecificProperty(providerSpecificGroup); ok { group = prop } service := Service{ Host: target, Text: ep.Labels["originalText"], Key: p.etcdKeyFor(prefix + "." + dnsName), TargetStrip: strings.Count(prefix, ".") + 1, TTL: uint32(ep.RecordTTL), Group: group, } services = append(services, &service) ep.Labels[target] = prefix } // Clean outdated labels for label, labelPrefix := range ep.Labels { if slices.Contains(skipLabels, label) { continue } if !slices.Contains(ep.Targets, label) { key := p.etcdKeyFor(labelPrefix + "." + dnsName) log.Infof("Delete key %s", key) if p.dryRun { continue } if err := p.client.DeleteService(ctx, key); err != nil { return nil, err } } } return services, nil } // updateTXTRecords updates the TXT records in the provided services slice based on the given group of endpoints. func (p coreDNSProvider) updateTXTRecords(dnsName string, group []*endpoint.Endpoint, services []*Service) []*Service { index := 0 for _, ep := range group { if ep.RecordType != endpoint.RecordTypeTXT { continue } if index >= len(services) { prefix := ep.Labels[randomPrefixLabel] if prefix == "" { prefix = fmt.Sprintf("%08x", rand.Int31()) } services = append(services, &Service{ Key: p.etcdKeyFor(prefix + "." + dnsName), TargetStrip: strings.Count(prefix, ".") + 1, TTL: uint32(ep.RecordTTL), }) } services[index].Text = ep.Targets[0] index++ } for i := index; index > 0 && i < len(services); i++ { services[i].Text = "" } return services } func (p coreDNSProvider) deleteEndpoints(ctx context.Context, endpoints []*endpoint.Endpoint) error { for _, ep := range endpoints { dnsName := ep.DNSName if ep.Labels[randomPrefixLabel] != "" { dnsName = ep.Labels[randomPrefixLabel] + "." + dnsName } key := p.etcdKeyFor(dnsName) log.Infof("Delete key %s", key) if p.dryRun { continue } if err := p.client.DeleteService(ctx, key); err != nil { return err } } return nil } func (p coreDNSProvider) etcdKeyFor(dnsName string) string { domains := strings.Split(dnsName, ".") reverse(domains) return p.coreDNSPrefix + strings.Join(domains, "/") } func guessRecordType(target string) string { if net.ParseIP(target) != nil { return endpoint.RecordTypeA } return endpoint.RecordTypeCNAME } func reverse(slice []string) { for i := range len(slice) / 2 { j := len(slice) - i - 1 slice[i], slice[j] = slice[j], slice[i] } } ================================================ FILE: provider/coredns/coredns_test.go ================================================ /* Copyright 2017 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 coredns import ( "context" "encoding/json" "errors" "reflect" "strings" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.etcd.io/etcd/api/v3/mvccpb" etcdcv3 "go.etcd.io/etcd/client/v3" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/plan" "github.com/stretchr/testify/require" ) const defaultCoreDNSPrefix = "/skydns/" type fakeETCDClient struct { services map[string]Service } func (c fakeETCDClient) GetServices(_ context.Context, prefix string) ([]*Service, error) { var result []*Service for key, value := range c.services { if strings.HasPrefix(key, prefix) { valueCopy := value valueCopy.Key = key result = append(result, &valueCopy) } } return result, nil } func (c fakeETCDClient) SaveService(_ context.Context, service *Service) error { c.services[service.Key] = *service return nil } func (c fakeETCDClient) DeleteService(_ context.Context, key string) error { delete(c.services, key) return nil } type MockEtcdKV struct { etcdcv3.KV mock.Mock } func (m *MockEtcdKV) Put(ctx context.Context, key, input string, _ ...etcdcv3.OpOption) (*etcdcv3.PutResponse, error) { args := m.Called(ctx, key, input) return args.Get(0).(*etcdcv3.PutResponse), args.Error(1) } func (m *MockEtcdKV) Get(ctx context.Context, key string, opts ...etcdcv3.OpOption) (*etcdcv3.GetResponse, error) { if len(opts) == 0 { args := m.Called(ctx, key) return args.Get(0).(*etcdcv3.GetResponse), args.Error(1) } else { args := m.Called(ctx, key, opts[0]) return args.Get(0).(*etcdcv3.GetResponse), args.Error(1) } } func (m *MockEtcdKV) Delete(ctx context.Context, key string, opts ...etcdcv3.OpOption) (*etcdcv3.DeleteResponse, error) { if len(opts) == 0 { args := m.Called(ctx, key) return args.Get(0).(*etcdcv3.DeleteResponse), args.Error(1) } else { args := m.Called(ctx, key, opts[0]) return args.Get(0).(*etcdcv3.DeleteResponse), args.Error(1) } } func TestETCDConfig(t *testing.T) { var tests = []struct { name string input map[string]string want *etcdcv3.Config }{ { "default config", map[string]string{}, &etcdcv3.Config{Endpoints: []string{"http://localhost:2379"}}, }, { "config with ETCD_URLS", map[string]string{"ETCD_URLS": "http://example.com:2379"}, &etcdcv3.Config{Endpoints: []string{"http://example.com:2379"}}, }, { "config with ETCD_USERNAME and ETCD_PASSWORD", map[string]string{"ETCD_USERNAME": "root", "ETCD_PASSWORD": "test"}, &etcdcv3.Config{ Endpoints: []string{"http://localhost:2379"}, Username: "root", Password: "test", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testutils.TestHelperEnvSetter(t, tt.input) cfg, _ := getETCDConfig() if !reflect.DeepEqual(cfg, tt.want) { t.Errorf("unexpected config. Got %v, want %v", cfg, tt.want) } }) } } func TestEtcdHttpsProtocol(t *testing.T) { envs := map[string]string{ "ETCD_URLS": "https://example.com:2379", } testutils.TestHelperEnvSetter(t, envs) cfg, err := getETCDConfig() assert.NoError(t, err) assert.NotNil(t, cfg) } func TestEtcdHttpsIncorrectConfigError(t *testing.T) { envs := map[string]string{ "ETCD_URLS": "https://example.com:2379", "ETCD_KEY_FILE": "incorrect-path-to-etcd-tls-key", } testutils.TestHelperEnvSetter(t, envs) _, err := getETCDConfig() assert.Errorf(t, err, "Error creating TLS config: either both cert and key or none must be provided") } func TestEtcdUnsupportedProtocolError(t *testing.T) { envs := map[string]string{ "ETCD_URLS": "jdbc:ftp:RemoteHost=MyFTPServer", } testutils.TestHelperEnvSetter(t, envs) _, err := getETCDConfig() assert.Errorf(t, err, "etcd URLs must start with either http:// or https://") } func TestAServiceTranslation(t *testing.T) { expectedTarget := "1.2.3.4" expectedDNSName := "example.com" expectedRecordType := endpoint.RecordTypeA client := fakeETCDClient{ map[string]Service{ "/skydns/com/example": {Host: expectedTarget}, }, } provider := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, } endpoints, err := provider.Records(t.Context()) require.NoError(t, err) if len(endpoints) != 1 { t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) } if endpoints[0].DNSName != expectedDNSName { t.Errorf("got unexpected DNS name: %s != %s", endpoints[0].DNSName, expectedDNSName) } if endpoints[0].Targets[0] != expectedTarget { t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[0], expectedTarget) } if endpoints[0].RecordType != expectedRecordType { t.Errorf("got unexpected DNS record type: %s != %s", endpoints[0].RecordType, expectedRecordType) } } func TestCNAMEServiceTranslation(t *testing.T) { expectedTarget := "example.net" expectedDNSName := "example.com" expectedRecordType := endpoint.RecordTypeCNAME client := fakeETCDClient{ map[string]Service{ "/skydns/com/example": {Host: expectedTarget}, }, } provider := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, } endpoints, err := provider.Records(t.Context()) require.NoError(t, err) if len(endpoints) != 1 { t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) } if endpoints[0].DNSName != expectedDNSName { t.Errorf("got unexpected DNS name: %s != %s", endpoints[0].DNSName, expectedDNSName) } if endpoints[0].Targets[0] != expectedTarget { t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[0], expectedTarget) } if endpoints[0].RecordType != expectedRecordType { t.Errorf("got unexpected DNS record type: %s != %s", endpoints[0].RecordType, expectedRecordType) } } func TestTXTServiceTranslation(t *testing.T) { expectedTarget := "string" expectedDNSName := "example.com" expectedRecordType := endpoint.RecordTypeTXT client := fakeETCDClient{ map[string]Service{ "/skydns/com/example": {Text: expectedTarget}, }, } provider := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, } endpoints, err := provider.Records(t.Context()) require.NoError(t, err) if len(endpoints) != 1 { t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) } if endpoints[0].DNSName != expectedDNSName { t.Errorf("got unexpected DNS name: %s != %s", endpoints[0].DNSName, expectedDNSName) } if endpoints[0].Targets[0] != expectedTarget { t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[0], expectedTarget) } if endpoints[0].RecordType != expectedRecordType { t.Errorf("got unexpected DNS record type: %s != %s", endpoints[0].RecordType, expectedRecordType) } } func TestAWithTXTServiceTranslation(t *testing.T) { expectedTargets := map[string]string{ endpoint.RecordTypeA: "1.2.3.4", endpoint.RecordTypeTXT: "string", } expectedDNSName := "example.com" client := fakeETCDClient{ map[string]Service{ "/skydns/com/example": {Host: "1.2.3.4", Text: "string"}, }, } provider := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, } endpoints, err := provider.Records(t.Context()) require.NoError(t, err) if len(endpoints) != len(expectedTargets) { t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) } for _, ep := range endpoints { expectedTarget := expectedTargets[ep.RecordType] if expectedTarget == "" { t.Errorf("got unexpected DNS record type: %s", ep.RecordType) continue } delete(expectedTargets, ep.RecordType) if ep.DNSName != expectedDNSName { t.Errorf("got unexpected DNS name: %s != %s", ep.DNSName, expectedDNSName) } if ep.Targets[0] != expectedTarget { t.Errorf("got unexpected DNS target: %s != %s", ep.Targets[0], expectedTarget) } } } func TestCNAMEWithTXTServiceTranslation(t *testing.T) { expectedTargets := map[string]string{ endpoint.RecordTypeCNAME: "example.net", endpoint.RecordTypeTXT: "string", } expectedDNSName := "example.com" client := fakeETCDClient{ map[string]Service{ "/skydns/com/example": {Host: "example.net", Text: "string"}, }, } provider := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, } endpoints, err := provider.Records(t.Context()) require.NoError(t, err) if len(endpoints) != len(expectedTargets) { t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) } for _, ep := range endpoints { expectedTarget := expectedTargets[ep.RecordType] if expectedTarget == "" { t.Errorf("got unexpected DNS record type: %s", ep.RecordType) continue } delete(expectedTargets, ep.RecordType) if ep.DNSName != expectedDNSName { t.Errorf("got unexpected DNS name: %s != %s", ep.DNSName, expectedDNSName) } if ep.Targets[0] != expectedTarget { t.Errorf("got unexpected DNS target: %s != %s", ep.Targets[0], expectedTarget) } } } func TestCoreDNSApplyChanges(t *testing.T) { client := fakeETCDClient{ map[string]Service{}, } coredns := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, } changes1 := &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "5.5.5.5"), endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeTXT, "string1"), endpoint.NewEndpoint("domain2.local", endpoint.RecordTypeCNAME, "site.local"), }, } err := coredns.ApplyChanges(t.Context(), changes1) require.NoError(t, err) expectedServices1 := map[string][]*Service{ "/skydns/local/domain1": {{Host: "5.5.5.5", Text: "string1"}}, "/skydns/local/domain2": {{Host: "site.local"}}, } validateServices(client.services, expectedServices1, t, 1) changes2 := &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("domain3.local", endpoint.RecordTypeA, "7.7.7.7"), }, UpdateNew: []*endpoint.Endpoint{ endpoint.NewEndpoint("domain1.local", "A", "6.6.6.6"), }, } records, _ := coredns.Records(t.Context()) for _, ep := range records { if ep.DNSName == "domain1.local" { changes2.UpdateOld = append(changes2.UpdateOld, ep) } } err = applyServiceChanges(coredns, changes2) require.NoError(t, err) expectedServices2 := map[string][]*Service{ "/skydns/local/domain1": {{Host: "6.6.6.6", Text: "string1"}}, "/skydns/local/domain2": {{Host: "site.local"}}, "/skydns/local/domain3": {{Host: "7.7.7.7"}}, } validateServices(client.services, expectedServices2, t, 2) changes3 := &plan.Changes{ Delete: []*endpoint.Endpoint{ endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "6.6.6.6"), endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeTXT, "string"), endpoint.NewEndpoint("domain3.local", endpoint.RecordTypeA, "7.7.7.7"), }, } err = applyServiceChanges(coredns, changes3) require.NoError(t, err) expectedServices3 := map[string][]*Service{ "/skydns/local/domain2": {{Host: "site.local"}}, } validateServices(client.services, expectedServices3, t, 3) // Test for multiple A records for the same FQDN changes4 := &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "5.5.5.5"), endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "6.6.6.6"), endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "7.7.7.7"), }, } err = coredns.ApplyChanges(t.Context(), changes4) require.NoError(t, err) expectedServices4 := map[string][]*Service{ "/skydns/local/domain2": {{Host: "site.local"}}, "/skydns/local/domain1": {{Host: "5.5.5.5"}, {Host: "6.6.6.6"}, {Host: "7.7.7.7"}}, } validateServices(client.services, expectedServices4, t, 4) } func TestCoreDNSApplyChanges_DomainDoNotMatch(t *testing.T) { client := fakeETCDClient{ map[string]Service{}, } coredns := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, domainFilter: endpoint.NewDomainFilter([]string{"example.local"}), } changes1 := &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "5.5.5.5"), endpoint.NewEndpoint("example.local", endpoint.RecordTypeTXT, "string1"), endpoint.NewEndpoint("domain2.local", endpoint.RecordTypeCNAME, "site.local"), }, } hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) err := coredns.ApplyChanges(t.Context(), changes1) require.NoError(t, err) logtest.TestHelperLogContains("Skipping record \"domain1.local\" due to domain filter", hook, t) logtest.TestHelperLogContains("Skipping record \"domain2.local\" due to domain filter", hook, t) } func applyServiceChanges(provider coreDNSProvider, changes *plan.Changes) error { ctx := context.Background() records, _ := provider.Records(ctx) for _, col := range [][]*endpoint.Endpoint{changes.Create, changes.UpdateNew, changes.Delete} { for _, record := range col { for _, existingRecord := range records { if existingRecord.DNSName == record.DNSName && existingRecord.RecordType == record.RecordType { mergeLabels(record, existingRecord.Labels) } } } } return provider.ApplyChanges(ctx, changes) } func validateServices(services map[string]Service, expectedServices map[string][]*Service, t *testing.T, step int) { t.Helper() for key, value := range services { keyParts := strings.Split(key, "/") expectedKey := strings.Join(keyParts[:len(keyParts)-value.TargetStrip], "/") expectedServiceEntries := expectedServices[expectedKey] if expectedServiceEntries == nil { t.Errorf("unexpected service %s", key) continue } found := false for i, expectedServiceEntry := range expectedServiceEntries { if value.Host == expectedServiceEntry.Host && value.Text == expectedServiceEntry.Text && value.Group == expectedServiceEntry.Group { expectedServiceEntries = append(expectedServiceEntries[:i], expectedServiceEntries[i+1:]...) found = true break } } if !found { t.Errorf("unexpected service %s: %s on step %d", key, value.Host, step) } if len(expectedServiceEntries) == 0 { delete(expectedServices, expectedKey) } else { expectedServices[expectedKey] = expectedServiceEntries } } if len(expectedServices) != 0 { t.Errorf("unmatched expected services: %+v on step %d", expectedServices, step) } } // mergeLabels adds keys to labels if not defined for the endpoint func mergeLabels(e *endpoint.Endpoint, labels map[string]string) { for k, v := range labels { if e.Labels[k] == "" { e.Labels[k] = v } } } func TestGetServices_Success(t *testing.T) { svc := Service{Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello"} value, err := json.Marshal(svc) require.NoError(t, err) mockKV := new(MockEtcdKV) mockKV.On("Get", mock.Anything, "/prefix", mock.AnythingOfType("clientv3.OpOption")). Return(&etcdcv3.GetResponse{ Kvs: []*mvccpb.KeyValue{ { Key: []byte("/prefix/1"), Value: value, }, }, }, nil) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, } result, err := c.GetServices(t.Context(), "/prefix") assert.NoError(t, err) assert.Len(t, result, 1) assert.Equal(t, "example.com", result[0].Host) } func TestGetServices_Duplicate(t *testing.T) { mockKV := new(MockEtcdKV) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, } svc := Service{Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello"} value, err := json.Marshal(svc) require.NoError(t, err) mockKV.On("Get", mock.Anything, "/prefix", mock.AnythingOfType("clientv3.OpOption")). Return(&etcdcv3.GetResponse{ Kvs: []*mvccpb.KeyValue{ { Key: []byte("/prefix/1"), Value: value, }, { Key: []byte("/prefix/1"), Value: value, }, }, }, nil) result, err := c.GetServices(t.Context(), "/prefix") assert.NoError(t, err) assert.Len(t, result, 1) } func TestGetServices_Multiple(t *testing.T) { mockKV := new(MockEtcdKV) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, } svc := Service{Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello"} value, err := json.Marshal(svc) require.NoError(t, err) svc2 := Service{Host: "example.com", Port: 80, Priority: 0, Weight: 10, Text: "hello"} value2, err := json.Marshal(svc2) require.NoError(t, err) mockKV.On("Get", mock.Anything, "/prefix", mock.AnythingOfType("clientv3.OpOption")). Return(&etcdcv3.GetResponse{ Kvs: []*mvccpb.KeyValue{ { Key: []byte("/prefix/1"), Value: value, }, { Key: []byte("/prefix/2"), Value: value2, }, }, }, nil) result, err := c.GetServices(t.Context(), "/prefix") assert.NoError(t, err) assert.Len(t, result, 2) assert.Equal(t, priority, result[1].Priority) } func TestGetServices_FilterOutOtherServicesOwnerSetButNothingChanged(t *testing.T) { mockKV := new(MockEtcdKV) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, owner: "owner", strictlyOwned: false, } svc := Service{Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Owner: "owner"} value, err := json.Marshal(svc) require.NoError(t, err) svc2 := Service{Host: "example.com", Port: 80, Priority: 0, Weight: 10, Text: "hello", Owner: ""} value2, err := json.Marshal(svc2) require.NoError(t, err) svc3 := Service{Host: "example.com", Port: 80, Priority: 0, Weight: 10, Text: "hello", Owner: "different-owner"} value3, err := json.Marshal(svc3) require.NoError(t, err) mockKV.On("Get", mock.Anything, "/prefix", mock.AnythingOfType("clientv3.OpOption")). Return(&etcdcv3.GetResponse{ Kvs: []*mvccpb.KeyValue{ { Key: []byte("/prefix/1"), Value: value, }, { Key: []byte("/prefix/2"), Value: value2, }, { Key: []byte("/prefix/3"), Value: value3, }, }, }, nil) result, err := c.GetServices(t.Context(), "/prefix") assert.NoError(t, err) assert.Len(t, result, 3) } func TestGetServices_FilterOutOtherServicesWithStrictlyOwned(t *testing.T) { mockKV := new(MockEtcdKV) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, owner: "owner", strictlyOwned: true, } svc := Service{Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Owner: "owner"} value, err := json.Marshal(svc) require.NoError(t, err) svc2 := Service{Host: "example.com", Port: 80, Priority: 0, Weight: 10, Text: "hello", Owner: ""} value2, err := json.Marshal(svc2) require.NoError(t, err) svc3 := Service{Host: "example.com", Port: 80, Priority: 0, Weight: 10, Text: "hello", Owner: "different-owner"} value3, err := json.Marshal(svc3) require.NoError(t, err) mockKV.On("Get", mock.Anything, "/prefix", mock.AnythingOfType("clientv3.OpOption")). Return(&etcdcv3.GetResponse{ Kvs: []*mvccpb.KeyValue{ { Key: []byte("/prefix/1"), Value: value, }, { Key: []byte("/prefix/2"), Value: value2, }, { Key: []byte("/prefix/3"), Value: value3, }, }, }, nil) result, err := c.GetServices(t.Context(), "/prefix") assert.NoError(t, err) assert.Len(t, result, 1) assert.Equal(t, "owner", result[0].Owner) } func TestGetServices_UnmarshalError(t *testing.T) { mockKV := new(MockEtcdKV) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, } mockKV.On("Get", mock.Anything, "/prefix", mock.AnythingOfType("clientv3.OpOption")). Return(&etcdcv3.GetResponse{ Kvs: []*mvccpb.KeyValue{ { Key: []byte("/prefix/1"), Value: []byte("invalid-json"), }, { Key: []byte("/prefix/1"), Value: []byte("invalid-json"), }, }, }, nil) _, err := c.GetServices(t.Context(), "/prefix") assert.Error(t, err) assert.Contains(t, err.Error(), "/prefix/1") } func TestGetServices_GetError(t *testing.T) { mockKV := new(MockEtcdKV) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, } mockKV.On("Get", mock.Anything, "/prefix", mock.AnythingOfType("clientv3.OpOption")). Return(&etcdcv3.GetResponse{}, errors.New("etcd failure")) _, err := c.GetServices(t.Context(), "/prefix") assert.Error(t, err) assert.EqualError(t, err, "etcd failure") } func TestDeleteService(t *testing.T) { tests := []struct { name string key string mockErr error wantErr bool }{ { name: "successful deletion", key: "/skydns/local/test", }, { name: "etcd error", key: "/skydns/local/test", mockErr: errors.New("etcd failure"), wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockKV := new(MockEtcdKV) mockKV.On("Delete", mock.Anything, mock.Anything, mock.AnythingOfType("clientv3.OpOption")). Return(&etcdcv3.DeleteResponse{}, tt.mockErr) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, } err := c.DeleteService(t.Context(), tt.key) if tt.wantErr { require.Error(t, err) assert.Equal(t, tt.mockErr, err) } else { require.NoError(t, err) } mockKV.AssertExpectations(t) }) } } func TestDeleteServiceWithStrictlyOwned(t *testing.T) { tests := []struct { name string owner string key string existingServices []Service deletedKeys []string }{ { name: "successful deletion with the same owner with strictly owned", key: "/skydns/local/test", owner: "owner", existingServices: []Service{{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/skydns/local/test", Owner: "owner", }}, deletedKeys: []string{"/skydns/local/test"}, }, { name: "prevent deletion of a service without an owner with strictly owned", key: "/skydns/local/test", owner: "owner", existingServices: []Service{{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/skydns/local/test", }}, deletedKeys: []string{}, }, { name: "prevent deletion with different owner with strictly owned", key: "/skydns/local/test", owner: "owner", existingServices: []Service{{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/skydns/local/test", Owner: "other-owner", }}, deletedKeys: []string{}, }, { name: "successful partial deletion for same owners with strictly owned", key: "/skydns/local/test", owner: "owner", existingServices: []Service{ { Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/skydns/local/test/1", Owner: "owner", }, { Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/skydns/local/test/2", }, { Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/skydns/local/test/3", Owner: "different-owner", }, { Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/skydns/local/test/4", Owner: "owner", }, }, deletedKeys: []string{"/skydns/local/test/1", "/skydns/local/test/4"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockKV := new(MockEtcdKV) for _, key := range tt.deletedKeys { mockKV.On("Delete", mock.Anything, key). Return(&etcdcv3.DeleteResponse{}, nil) } kvs := []*mvccpb.KeyValue{} for _, service := range tt.existingServices { actualValue, err := json.Marshal(&service) require.NoError(t, err) kvs = append(kvs, &mvccpb.KeyValue{ Key: []byte(service.Key), Value: actualValue, }) } mockKV.On("Get", mock.Anything, tt.key, mock.AnythingOfType("clientv3.OpOption")).Return(&etcdcv3.GetResponse{ Kvs: kvs, }, nil) c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, owner: tt.owner, strictlyOwned: true, } err := c.DeleteService(t.Context(), tt.key) require.NoError(t, err) mockKV.AssertExpectations(t) }) } } func TestSaveService(t *testing.T) { type testCase struct { name string owner string strictlyOwned bool service *Service expectedService *Service exists bool ignoreGetCall bool mockPutErr error wantErr bool } tests := []testCase{ { name: "success", service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", }, expectedService: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", }, }, { name: "success with 'owner' without strictly owned", owner: "owner", exists: true, service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", }, expectedService: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", }, }, { name: "success with 'owner' (creation) without strictly owned", owner: "owner", exists: false, service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", }, expectedService: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", }, }, { name: "success with 'owner' (update) without strictly owned (owner not changed)", owner: "owner", exists: true, service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", Owner: "owner", }, expectedService: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", Owner: "owner", }, }, { name: "success with different 'owner' without strictly owned", owner: "owner", exists: true, service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", Owner: "other-owner", }, expectedService: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", Owner: "other-owner", }, }, { name: "failed with 'owner' is empty with strictly owned", owner: "owner", strictlyOwned: true, exists: true, service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", }, wantErr: true, }, { name: "success with 'owner' (creation) with strictly owned", owner: "owner", strictlyOwned: true, exists: false, service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", }, expectedService: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", Owner: "owner", }, }, { name: "success with 'owner' (update) with strictly owned (owner not changed)", owner: "owner", strictlyOwned: true, exists: true, ignoreGetCall: true, service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", Owner: "owner", }, expectedService: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", Owner: "owner", }, }, { name: "failed with different 'owner' with strictly owned", owner: "owner", strictlyOwned: true, exists: true, service: &Service{ Host: "example.com", Port: 80, Priority: 1, Weight: 10, Text: "hello", Key: "/prefix/1", Owner: "other-owner", }, wantErr: true, }, { name: "etcd put error", service: &Service{ Host: "example.com", Key: "/prefix/2", }, expectedService: &Service{ Host: "example.com", Key: "/prefix/2", }, mockPutErr: errors.New("etcd failure"), wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockKV := new(MockEtcdKV) value, err := json.Marshal(&tt.expectedService) require.NoError(t, err) if tt.expectedService != nil { mockKV.On("Put", mock.Anything, tt.service.Key, string(value)). Return(&etcdcv3.PutResponse{}, tt.mockPutErr) } actualValue, err := json.Marshal(&tt.service) require.NoError(t, err) if tt.strictlyOwned && !tt.ignoreGetCall { if tt.exists { mockKV.On("Get", mock.Anything, tt.service.Key).Return(&etcdcv3.GetResponse{ Kvs: []*mvccpb.KeyValue{ { Key: []byte(tt.service.Key), Value: actualValue, }, }, }, nil) } else { mockKV.On("Get", mock.Anything, tt.service.Key).Return(&etcdcv3.GetResponse{ Kvs: []*mvccpb.KeyValue{}, }, nil) } } c := etcdClient{ client: &etcdcv3.Client{ KV: mockKV, }, owner: tt.owner, strictlyOwned: tt.strictlyOwned, } err = c.SaveService(t.Context(), tt.service) if tt.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) } mockKV.AssertExpectations(t) }) } } func TestNewProvider(t *testing.T) { tests := []struct { name string envs map[string]string wantErr bool errMsg string }{ { name: "default config", envs: map[string]string{}, }, { name: "config with ETCD_URLS", envs: map[string]string{"ETCD_URLS": "http://example.com:2379"}, }, { name: "config with unsupported protocol", envs: map[string]string{"ETCD_URLS": "ftp://example.com:20"}, wantErr: true, errMsg: "etcd URLs must start with either http:// or https://", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testutils.TestHelperEnvSetter(t, tt.envs) provider, err := newProvider(&endpoint.DomainFilter{}, "/prefix/", "", false, false) if tt.wantErr { require.Error(t, err) assert.EqualError(t, err, tt.errMsg) } else { require.NoError(t, err) require.NotNil(t, provider) } }) } } func TestFindEp(t *testing.T) { tests := []struct { name string slice []*endpoint.Endpoint dnsName string want *endpoint.Endpoint wantBool bool }{ { name: "found", slice: []*endpoint.Endpoint{ {DNSName: "foo.example.com"}, {DNSName: "bar.example.com"}, }, dnsName: "bar.example.com", want: &endpoint.Endpoint{DNSName: "bar.example.com"}, wantBool: true, }, { name: "not found", slice: []*endpoint.Endpoint{ {DNSName: "foo.example.com"}, }, dnsName: "baz.example.com", want: nil, wantBool: false, }, { name: "empty slice", slice: []*endpoint.Endpoint{}, dnsName: "foo.example.com", want: nil, wantBool: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, ok := findEp(tt.slice, tt.dnsName) assert.Equal(t, tt.wantBool, ok) if ok { assert.Equal(t, tt.dnsName, got.DNSName) } else { assert.Nil(t, got) } }) } } func TestCoreDNSProvider_updateTXTRecords_WithEdpoints(t *testing.T) { provider := coreDNSProvider{coreDNSPrefix: "/prefix/"} dnsName := "foo.example.com" group := []*endpoint.Endpoint{ { RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"txt-value"}, Labels: map[string]string{randomPrefixLabel: "pfx"}, RecordTTL: 60, }, { RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"txt-value-2"}, Labels: map[string]string{randomPrefixLabel: ""}, RecordTTL: 60, }, } services := provider.updateTXTRecords(dnsName, group, []*Service{}) assert.Len(t, services, 2) assert.Equal(t, "txt-value", services[0].Text) assert.Equal(t, "txt-value-2", services[1].Text) } func TestCoreDNSProvider_updateTXTRecords_ClearsExtraText(t *testing.T) { provider := coreDNSProvider{coreDNSPrefix: "/prefix/"} dnsName := "foo.example.com" group := []*endpoint.Endpoint{ { RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"txt-value"}, Labels: map[string]string{randomPrefixLabel: "pfx"}, RecordTTL: 60, }, } var services []*Service services = append(services, &Service{Key: "/prefix/1", Text: "should-be-txt-value"}) services = append(services, &Service{Key: "/prefix/2", Text: "should-be-empty"}) services = append(services, &Service{Key: "/prefix/3", Text: "should-be-empty"}) services = provider.updateTXTRecords(dnsName, group, services) assert.Len(t, services, 3) assert.Equal(t, "txt-value", services[0].Text) assert.Empty(t, services[1].Text) } func TestApplyChangesAWithGroupServiceTranslation(t *testing.T) { client := fakeETCDClient{ map[string]Service{}, } coredns := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, } changes1 := &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "5.5.5.5").WithProviderSpecific(providerSpecificGroup, "test1"), endpoint.NewEndpoint("domain2.local", endpoint.RecordTypeA, "5.5.5.6").WithProviderSpecific(providerSpecificGroup, "test1"), endpoint.NewEndpoint("domain3.local", endpoint.RecordTypeA, "5.5.5.7").WithProviderSpecific(providerSpecificGroup, "test2"), }, } coredns.ApplyChanges(t.Context(), changes1) expectedServices1 := map[string][]*Service{ "/skydns/local/domain1": {{Host: "5.5.5.5", Group: "test1"}}, "/skydns/local/domain2": {{Host: "5.5.5.6", Group: "test1"}}, "/skydns/local/domain3": {{Host: "5.5.5.7", Group: "test2"}}, } validateServices(client.services, expectedServices1, t, 1) } func TestRecordsAWithGroupServiceTranslation(t *testing.T) { client := fakeETCDClient{ map[string]Service{ "/skydns/local/domain1": {Host: "5.5.5.5", Group: "test1"}, }, } coredns := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, } endpoints, err := coredns.Records(t.Context()) require.NoError(t, err) if prop, ok := endpoints[0].GetProviderSpecificProperty(providerSpecificGroup); !ok { t.Error("go no Group name") } else if prop != "test1" { t.Errorf("got unexpected Group name: %s != %s", prop, "test1") } } func TestRecordsIncludeLabelOwnerWithStrictlyOwned(t *testing.T) { client := fakeETCDClient{ map[string]Service{ "/skydns/local/domain1": {Host: "5.5.5.5", Group: "test1", Owner: "owner"}, "/skydns/com/example": {Text: "bla", Owner: "owner"}, }, } coredns := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, strictlyOwned: true, } endpoints, err := coredns.Records(t.Context()) require.NoError(t, err) for _, ep := range endpoints { assert.Equal(t, "owner", ep.Labels[endpoint.OwnerLabelKey]) } } func TestRecordsIncludeOwnerASLabelWithoutStrictlyOwned(t *testing.T) { client := fakeETCDClient{ map[string]Service{ "/skydns/local/domain1": {Host: "5.5.5.5", Group: "test1", Owner: "owner"}, "/skydns/com/example": {Text: "bla", Owner: "owner"}, }, } coredns := coreDNSProvider{ client: client, coreDNSPrefix: defaultCoreDNSPrefix, strictlyOwned: false, } endpoints, err := coredns.Records(t.Context()) require.NoError(t, err) for _, ep := range endpoints { assert.Empty(t, ep.Labels[endpoint.OwnerLabelKey]) } } ================================================ FILE: provider/dnsimple/dnsimple.go ================================================ /* Copyright 2017 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 dnsimple import ( "context" "fmt" "os" "strconv" "strings" "github.com/dnsimple/dnsimple-go/dnsimple" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( dnsimpleCreate = "CREATE" dnsimpleDelete = "DELETE" dnsimpleUpdate = "UPDATE" defaultTTL = 3600 // Default TTL of 1 hour if not set (DNSimple's default) ) type dnsimpleIdentityService struct { service *dnsimple.IdentityService } func (i dnsimpleIdentityService) Whoami(ctx context.Context) (*dnsimple.WhoamiResponse, error) { return i.service.Whoami(ctx) } // dnsimpleZoneServiceInterface is an interface that contains all necessary zone services from DNSimple type dnsimpleZoneServiceInterface interface { ListZones(ctx context.Context, accountID string, options *dnsimple.ZoneListOptions) (*dnsimple.ZonesResponse, error) ListRecords(ctx context.Context, accountID string, zoneID string, options *dnsimple.ZoneRecordListOptions) (*dnsimple.ZoneRecordsResponse, error) CreateRecord(ctx context.Context, accountID string, zoneID string, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) DeleteRecord(ctx context.Context, accountID string, zoneID string, recordID int64) (*dnsimple.ZoneRecordResponse, error) UpdateRecord(ctx context.Context, accountID string, zoneID string, recordID int64, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) } type dnsimpleZoneService struct { service *dnsimple.ZonesService } func (z dnsimpleZoneService) ListZones(ctx context.Context, accountID string, options *dnsimple.ZoneListOptions) (*dnsimple.ZonesResponse, error) { return z.service.ListZones(ctx, accountID, options) } func (z dnsimpleZoneService) ListRecords(ctx context.Context, accountID string, zoneID string, options *dnsimple.ZoneRecordListOptions) (*dnsimple.ZoneRecordsResponse, error) { return z.service.ListRecords(ctx, accountID, zoneID, options) } func (z dnsimpleZoneService) CreateRecord(ctx context.Context, accountID string, zoneID string, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) { return z.service.CreateRecord(ctx, accountID, zoneID, recordAttributes) } func (z dnsimpleZoneService) DeleteRecord(ctx context.Context, accountID string, zoneID string, recordID int64) (*dnsimple.ZoneRecordResponse, error) { return z.service.DeleteRecord(ctx, accountID, zoneID, recordID) } func (z dnsimpleZoneService) UpdateRecord(ctx context.Context, accountID string, zoneID string, recordID int64, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) { return z.service.UpdateRecord(ctx, accountID, zoneID, recordID, recordAttributes) } type dnsimpleProvider struct { provider.BaseProvider client dnsimpleZoneServiceInterface identity dnsimpleIdentityService accountID string domainFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter dryRun bool } type dnsimpleChange struct { Action string ResourceRecordSet dnsimple.ZoneRecord } // New creates a DNSimple provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.DryRun) } // newProvider initializes a new Dnsimple based provider func newProvider(domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool) (provider.Provider, error) { oauthToken := os.Getenv("DNSIMPLE_OAUTH") if len(oauthToken) == 0 { return nil, fmt.Errorf("no dnsimple oauth token provided") } ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: oauthToken}) tc := oauth2.NewClient(context.Background(), ts) client := dnsimple.NewClient(tc) client.SetUserAgent(externaldns.UserAgent()) provider := &dnsimpleProvider{ client: dnsimpleZoneService{service: client.Zones}, identity: dnsimpleIdentityService{service: client.Identity}, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, dryRun: dryRun, } provider.accountID = os.Getenv("DNSIMPLE_ACCOUNT_ID") if provider.accountID == "" { whoamiResponse, err := provider.identity.Whoami(context.Background()) if err != nil { return nil, err } provider.accountID = int64ToString(whoamiResponse.Data.Account.ID) } return provider, nil } // GetAccountID returns the account ID given DNSimple credentials. func (p *dnsimpleProvider) GetAccountID(ctx context.Context) (string, error) { // get DNSimple client accountID whoamiResponse, err := p.identity.Whoami(ctx) if err != nil { return "", err } return int64ToString(whoamiResponse.Data.Account.ID), nil } func ZonesFromZoneString(zonestring string) map[string]dnsimple.Zone { zones := make(map[string]dnsimple.Zone) zoneNames := strings.Split(zonestring, ",") for indexId, zoneName := range zoneNames { zone := dnsimple.Zone{Name: zoneName, ID: int64(indexId)} zones[int64ToString(zone.ID)] = zone } return zones } // Zones Return a list of filtered Zones func (p *dnsimpleProvider) Zones(ctx context.Context) (map[string]dnsimple.Zone, error) { zones := make(map[string]dnsimple.Zone) // If the DNSIMPLE_ZONES environment variable is specified, generate a list of Zones from it // This is useful for when the DNSIMPLE_OAUTH environment variable is a User API token and // not an Account API token as the User API token will not have permissions to list Zones // belong to another account which the User has access permissions for. envZonesStr := os.Getenv("DNSIMPLE_ZONES") if envZonesStr != "" { return ZonesFromZoneString(envZonesStr), nil } page := 1 listOptions := &dnsimple.ZoneListOptions{} for { listOptions.Page = &page zonesResponse, err := p.client.ListZones(ctx, p.accountID, listOptions) if err != nil { return nil, err } for _, zone := range zonesResponse.Data { if !p.domainFilter.Match(zone.Name) { continue } if !p.zoneIDFilter.Match(int64ToString(zone.ID)) { continue } zones[int64ToString(zone.ID)] = zone } page++ if page > zonesResponse.Pagination.TotalPages { break } } return zones, nil } // Records returns a list of endpoints in a given zone func (p *dnsimpleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.Zones(ctx) if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, 0) for _, zone := range zones { page := 1 listOptions := &dnsimple.ZoneRecordListOptions{} for { listOptions.Page = &page records, err := p.client.ListRecords(ctx, p.accountID, zone.Name, listOptions) if err != nil { return nil, err } for _, record := range records.Data { if record.Type != endpoint.RecordTypeA && record.Type != endpoint.RecordTypeCNAME && record.Type != endpoint.RecordTypeTXT { continue } // Apex records have an empty string for their name. // Consider this when creating the endpoint dnsName dnsName := fmt.Sprintf("%s.%s", record.Name, record.ZoneID) if record.Name == "" { dnsName = record.ZoneID } endpoints = append(endpoints, endpoint.NewEndpointWithTTL(dnsName, record.Type, endpoint.TTL(record.TTL), record.Content)) } page++ if page > records.Pagination.TotalPages { break } } } return endpoints, nil } // newDnsimpleChange initializes a new change to dns records func newDnsimpleChange(action string, e *endpoint.Endpoint) *dnsimpleChange { ttl := defaultTTL if e.RecordTTL.IsConfigured() { ttl = int(e.RecordTTL) } change := &dnsimpleChange{ Action: action, ResourceRecordSet: dnsimple.ZoneRecord{ Name: e.DNSName, Type: e.RecordType, Content: e.Targets[0], TTL: ttl, }, } return change } // newDnsimpleChanges returns a slice of changes based on given action and record func newDnsimpleChanges(action string, endpoints []*endpoint.Endpoint) []*dnsimpleChange { changes := make([]*dnsimpleChange, 0, len(endpoints)) for _, e := range endpoints { changes = append(changes, newDnsimpleChange(action, e)) } return changes } // submitChanges takes a zone and a collection of changes and makes all changes from the collection func (p *dnsimpleProvider) submitChanges(ctx context.Context, changes []*dnsimpleChange) error { if len(changes) == 0 { log.Infof("All records are already up to date") return nil } zones, err := p.Zones(ctx) if err != nil { return err } for _, change := range changes { zone := dnsimpleSuitableZone(change.ResourceRecordSet.Name, zones) if zone == nil { log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", change.ResourceRecordSet.Name) continue } log.Infof("Changing records: %s %v in zone: %s", change.Action, change.ResourceRecordSet, zone.Name) if change.ResourceRecordSet.Name == zone.Name { change.ResourceRecordSet.Name = "" // Apex records have an empty name } else { change.ResourceRecordSet.Name = strings.TrimSuffix(change.ResourceRecordSet.Name, fmt.Sprintf(".%s", zone.Name)) } recordAttributes := dnsimple.ZoneRecordAttributes{ Name: &change.ResourceRecordSet.Name, Type: change.ResourceRecordSet.Type, Content: change.ResourceRecordSet.Content, TTL: change.ResourceRecordSet.TTL, } if !p.dryRun { switch change.Action { case dnsimpleCreate: _, err := p.client.CreateRecord(ctx, p.accountID, zone.Name, recordAttributes) if err != nil { return err } case dnsimpleDelete: recordID, err := p.GetRecordID(ctx, zone.Name, *recordAttributes.Name) if err != nil { return err } _, err = p.client.DeleteRecord(ctx, p.accountID, zone.Name, recordID) if err != nil { return err } case dnsimpleUpdate: recordID, err := p.GetRecordID(ctx, zone.Name, *recordAttributes.Name) if err != nil { return err } _, err = p.client.UpdateRecord(ctx, p.accountID, zone.Name, recordID, recordAttributes) if err != nil { return err } } } } return nil } // GetRecordID returns the record ID for a given record name and zone. func (p *dnsimpleProvider) GetRecordID(ctx context.Context, zone string, recordName string) (int64, error) { page := 1 listOptions := &dnsimple.ZoneRecordListOptions{Name: &recordName} for { listOptions.Page = &page records, err := p.client.ListRecords(ctx, p.accountID, zone, listOptions) if err != nil { return 0, err } for _, record := range records.Data { if record.Name == recordName { return record.ID, nil } } page++ if page > records.Pagination.TotalPages { break } } return 0, fmt.Errorf("no record id found") } // dnsimpleSuitableZone returns the most suitable zone for a given hostname and a set of zones. func dnsimpleSuitableZone(hostname string, zones map[string]dnsimple.Zone) *dnsimple.Zone { var zone *dnsimple.Zone for _, z := range zones { if strings.HasSuffix(hostname, z.Name) { if zone == nil || len(z.Name) > len(zone.Name) { newZ := z zone = &newZ } } } return zone } // ApplyChanges applies a given set of changes func (p *dnsimpleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { combinedChanges := make([]*dnsimpleChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) combinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleCreate, changes.Create)...) combinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleUpdate, changes.UpdateNew)...) combinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleDelete, changes.Delete)...) return p.submitChanges(ctx, combinedChanges) } func int64ToString(i int64) string { return strconv.FormatInt(i, 10) } ================================================ FILE: provider/dnsimple/dnsimple_test.go ================================================ /* Copyright 2017 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 dnsimple import ( "context" "fmt" "os" "testing" "github.com/dnsimple/dnsimple-go/dnsimple" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) var ( mockProvider dnsimpleProvider dnsimpleListRecordsResponse dnsimple.ZoneRecordsResponse dnsimpleListZonesResponse dnsimple.ZonesResponse dnsimpleListZonesFromEnvResponse dnsimple.ZonesResponse ) func TestDnsimpleServices(t *testing.T) { // Setup example responses firstZone := dnsimple.Zone{ ID: 1, AccountID: 12345, Name: "example.com", } secondZone := dnsimple.Zone{ ID: 2, AccountID: 54321, Name: "example-beta.com", } zones := []dnsimple.Zone{firstZone, secondZone} dnsimpleListZonesResponse = dnsimple.ZonesResponse{ Response: dnsimple.Response{Pagination: &dnsimple.Pagination{}}, Data: zones, } firstEnvDefinedZone := dnsimple.Zone{ ID: 0, AccountID: 12345, Name: "example-from-env.com", } envDefinedZones := []dnsimple.Zone{firstEnvDefinedZone} dnsimpleListZonesFromEnvResponse = dnsimple.ZonesResponse{ Response: dnsimple.Response{Pagination: &dnsimple.Pagination{}}, Data: envDefinedZones, } firstRecord := dnsimple.ZoneRecord{ ID: 2, ZoneID: "example.com", ParentID: 0, Name: "example", Content: "target", TTL: 3600, Priority: 0, Type: "CNAME", } secondRecord := dnsimple.ZoneRecord{ ID: 1, ZoneID: "example.com", ParentID: 0, Name: "example-beta", Content: "127.0.0.1", TTL: 3600, Priority: 0, Type: "A", } thirdRecord := dnsimple.ZoneRecord{ ID: 3, ZoneID: "example.com", ParentID: 0, Name: "custom-ttl", Content: "target", TTL: 60, Priority: 0, Type: "CNAME", } fourthRecord := dnsimple.ZoneRecord{ ID: 4, ZoneID: "example.com", ParentID: 0, Name: "", // Apex domain A record Content: "127.0.0.1", TTL: 3600, Priority: 0, Type: "A", } records := []dnsimple.ZoneRecord{firstRecord, secondRecord, thirdRecord, fourthRecord} dnsimpleListRecordsResponse = dnsimple.ZoneRecordsResponse{ Response: dnsimple.Response{Pagination: &dnsimple.Pagination{}}, Data: records, } // Setup mock services // Note: AnythingOfType doesn't work with interfaces https://github.com/stretchr/testify/issues/519 mockDNS := &mockDnsimpleZoneServiceInterface{} mockDNS.On("ListZones", t.Context(), "1", &dnsimple.ZoneListOptions{ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(&dnsimpleListZonesResponse, nil) mockDNS.On("ListZones", t.Context(), "2", &dnsimple.ZoneListOptions{ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(nil, fmt.Errorf("Account ID not found")) mockDNS.On("ListRecords", t.Context(), "1", "example.com", &dnsimple.ZoneRecordListOptions{ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(&dnsimpleListRecordsResponse, nil) mockDNS.On("ListRecords", t.Context(), "1", "example-beta.com", &dnsimple.ZoneRecordListOptions{ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(&dnsimple.ZoneRecordsResponse{Response: dnsimple.Response{Pagination: &dnsimple.Pagination{}}}, nil) for _, record := range records { recordName := record.Name simpleRecord := dnsimple.ZoneRecordAttributes{ Name: &recordName, Type: record.Type, Content: record.Content, TTL: record.TTL, } dnsimpleRecordResponse := dnsimple.ZoneRecordsResponse{ Response: dnsimple.Response{Pagination: &dnsimple.Pagination{}}, Data: []dnsimple.ZoneRecord{record}, } mockDNS.On("ListRecords", t.Context(), "1", record.ZoneID, &dnsimple.ZoneRecordListOptions{Name: &recordName, ListOptions: dnsimple.ListOptions{Page: dnsimple.Int(1)}}).Return(&dnsimpleRecordResponse, nil) mockDNS.On("CreateRecord", t.Context(), "1", record.ZoneID, simpleRecord).Return(&dnsimple.ZoneRecordResponse{}, nil) mockDNS.On("DeleteRecord", t.Context(), "1", record.ZoneID, record.ID).Return(&dnsimple.ZoneRecordResponse{}, nil) mockDNS.On("UpdateRecord", t.Context(), "1", record.ZoneID, record.ID, simpleRecord).Return(&dnsimple.ZoneRecordResponse{}, nil) } mockProvider = dnsimpleProvider{client: mockDNS} // Run tests on mock services t.Run("Zones", testDnsimpleProviderZones) t.Run("Records", testDnsimpleProviderRecords) t.Run("ApplyChanges", testDnsimpleProviderApplyChanges) t.Run("ApplyChanges/SkipUnknownZone", testDnsimpleProviderApplyChangesSkipsUnknown) t.Run("SuitableZone", testDnsimpleSuitableZone) t.Run("GetRecordID", testDnsimpleGetRecordID) } func testDnsimpleProviderZones(t *testing.T) { ctx := t.Context() mockProvider.accountID = "1" result, err := mockProvider.Zones(ctx) assert.NoError(t, err) validateDnsimpleZones(t, result, dnsimpleListZonesResponse.Data) mockProvider.accountID = "2" _, err = mockProvider.Zones(ctx) assert.Error(t, err) mockProvider.accountID = "3" t.Setenv("DNSIMPLE_ZONES", "example-from-env.com") result, err = mockProvider.Zones(ctx) assert.NoError(t, err) validateDnsimpleZones(t, result, dnsimpleListZonesFromEnvResponse.Data) mockProvider.accountID = "2" os.Unsetenv("DNSIMPLE_ZONES") } func testDnsimpleProviderRecords(t *testing.T) { ctx := t.Context() mockProvider.accountID = "1" result, err := mockProvider.Records(ctx) assert.NoError(t, err) assert.Len(t, result, len(dnsimpleListRecordsResponse.Data)) mockProvider.accountID = "2" _, err = mockProvider.Records(ctx) assert.Error(t, err) } func testDnsimpleProviderApplyChanges(t *testing.T) { changes := &plan.Changes{} changes.Create = []*endpoint.Endpoint{ {DNSName: "example.example.com", Targets: endpoint.Targets{"target"}, RecordType: endpoint.RecordTypeCNAME}, {DNSName: "custom-ttl.example.com", RecordTTL: 60, Targets: endpoint.Targets{"target"}, RecordType: endpoint.RecordTypeCNAME}, } changes.Delete = []*endpoint.Endpoint{ {DNSName: "example-beta.example.com", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: endpoint.RecordTypeA}, } changes.UpdateNew = []*endpoint.Endpoint{ {DNSName: "example.example.com", Targets: endpoint.Targets{"target"}, RecordType: endpoint.RecordTypeCNAME}, {DNSName: "example.com", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: endpoint.RecordTypeA}, } mockProvider.accountID = "1" err := mockProvider.ApplyChanges(t.Context(), changes) if err != nil { t.Errorf("Failed to apply changes: %v", err) } } func testDnsimpleProviderApplyChangesSkipsUnknown(t *testing.T) { changes := &plan.Changes{} changes.Create = []*endpoint.Endpoint{ {DNSName: "example.not-included.com", Targets: endpoint.Targets{"dasd"}, RecordType: endpoint.RecordTypeCNAME}, } mockProvider.accountID = "1" err := mockProvider.ApplyChanges(t.Context(), changes) if err != nil { t.Errorf("Failed to ignore unknown zones: %v", err) } } func testDnsimpleSuitableZone(t *testing.T) { ctx := t.Context() mockProvider.accountID = "1" zones, err := mockProvider.Zones(ctx) require.NoError(t, err) zone := dnsimpleSuitableZone("example-beta.example.com", zones) assert.Equal(t, "example.com", zone.Name) t.Setenv("DNSIMPLE_ZONES", "environment-example.com,example.environment-example.com") mockProvider.accountID = "3" zones, err = mockProvider.Zones(ctx) require.NoError(t, err) zone = dnsimpleSuitableZone("hello.example.environment-example.com", zones) assert.Equal(t, "example.environment-example.com", zone.Name) _ = os.Unsetenv("DNSIMPLE_ZONES") mockProvider.accountID = "1" } func TestNewProvider(t *testing.T) { t.Setenv("DNSIMPLE_OAUTH", "xxxxxxxxxxxxxxxxxxxxxxxxxx") _, err := newProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true) if err == nil { t.Errorf("Expected to fail new provider on bad token") } _ = os.Unsetenv("DNSIMPLE_OAUTH") _, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true) if err == nil { t.Errorf("Expected to fail new provider on empty token") } t.Setenv("DNSIMPLE_OAUTH", "xxxxxxxxxxxxxxxxxxxxxxxxxx") t.Setenv("DNSIMPLE_ACCOUNT_ID", "12345678") providerTypedProvider, err := newProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true) dnsimpleTypedProvider := providerTypedProvider.(*dnsimpleProvider) if err != nil { t.Errorf("Unexpected error thrown when testing NewDnsimpleProvider with the DNSIMPLE_ACCOUNT_ID environment variable set") } assert.Equal(t, "12345678", dnsimpleTypedProvider.accountID) os.Unsetenv("DNSIMPLE_OAUTH") os.Unsetenv("DNSIMPLE_ACCOUNT_ID") } func testDnsimpleGetRecordID(t *testing.T) { var result int64 var err error mockProvider.accountID = "1" result, err = mockProvider.GetRecordID(t.Context(), "example.com", "example") assert.NoError(t, err) assert.Equal(t, int64(2), result) result, err = mockProvider.GetRecordID(t.Context(), "example.com", "example-beta") assert.NoError(t, err) assert.Equal(t, int64(1), result) } func validateDnsimpleZones(t *testing.T, zones map[string]dnsimple.Zone, expected []dnsimple.Zone) { require.Len(t, zones, len(expected)) for _, e := range expected { assert.Equal(t, zones[int64ToString(e.ID)].Name, e.Name) } } type mockDnsimpleZoneServiceInterface struct { mock.Mock } func (_m *mockDnsimpleZoneServiceInterface) CreateRecord(ctx context.Context, accountID string, zoneID string, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) { args := _m.Called(ctx, accountID, zoneID, recordAttributes) var r0 *dnsimple.ZoneRecordResponse if args.Get(0) != nil { r0 = args.Get(0).(*dnsimple.ZoneRecordResponse) } return r0, args.Error(1) } func (_m *mockDnsimpleZoneServiceInterface) DeleteRecord(ctx context.Context, accountID string, zoneID string, recordID int64) (*dnsimple.ZoneRecordResponse, error) { args := _m.Called(ctx, accountID, zoneID, recordID) var r0 *dnsimple.ZoneRecordResponse if args.Get(0) != nil { r0 = args.Get(0).(*dnsimple.ZoneRecordResponse) } return r0, args.Error(1) } func (_m *mockDnsimpleZoneServiceInterface) ListRecords(ctx context.Context, accountID string, zoneID string, options *dnsimple.ZoneRecordListOptions) (*dnsimple.ZoneRecordsResponse, error) { args := _m.Called(ctx, accountID, zoneID, options) var r0 *dnsimple.ZoneRecordsResponse if args.Get(0) != nil { r0 = args.Get(0).(*dnsimple.ZoneRecordsResponse) } return r0, args.Error(1) } func (_m *mockDnsimpleZoneServiceInterface) ListZones(ctx context.Context, accountID string, options *dnsimple.ZoneListOptions) (*dnsimple.ZonesResponse, error) { args := _m.Called(ctx, accountID, options) var r0 *dnsimple.ZonesResponse if args.Get(0) != nil { r0 = args.Get(0).(*dnsimple.ZonesResponse) } return r0, args.Error(1) } func (_m *mockDnsimpleZoneServiceInterface) UpdateRecord(ctx context.Context, accountID string, zoneID string, recordID int64, recordAttributes dnsimple.ZoneRecordAttributes) (*dnsimple.ZoneRecordResponse, error) { args := _m.Called(ctx, accountID, zoneID, recordID, recordAttributes) var r0 *dnsimple.ZoneRecordResponse if args.Get(0) != nil { r0 = args.Get(0).(*dnsimple.ZoneRecordResponse) } return r0, args.Error(1) } ================================================ FILE: provider/exoscale/exoscale.go ================================================ /* Copyright 2017 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 exoscale import ( "context" "strings" egoscale "github.com/exoscale/egoscale/v2" exoapi "github.com/exoscale/egoscale/v2/api" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) // EgoscaleClientI for replaceable implementation type EgoscaleClientI interface { ListDNSDomainRecords(context.Context, string, string) ([]egoscale.DNSDomainRecord, error) ListDNSDomains(context.Context, string) ([]egoscale.DNSDomain, error) CreateDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) (*egoscale.DNSDomainRecord, error) DeleteDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) error UpdateDNSDomainRecord(context.Context, string, string, *egoscale.DNSDomainRecord) error } // ExoscaleProvider initialized as dns provider with no records type ExoscaleProvider struct { provider.BaseProvider domain *endpoint.DomainFilter client EgoscaleClientI apiEnv string apiZone string filter *zoneFilter OnApplyChanges func(changes *plan.Changes) dryRun bool } // ExoscaleOption for Provider options type ExoscaleOption func(*ExoscaleProvider) // New creates an Exoscale provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider( cfg.ExoscaleAPIEnvironment, cfg.ExoscaleAPIZone, cfg.ExoscaleAPIKey, cfg.ExoscaleAPISecret, cfg.DryRun, ExoscaleWithDomain(domainFilter), ExoscaleWithLogging(), ) } // newProvider returns ExoscaleProvider DNS provider interface implementation func newProvider(env, zone, key, secret string, dryRun bool, opts ...ExoscaleOption) (*ExoscaleProvider, error) { client, err := egoscale.NewClient( key, secret, ) if err != nil { return nil, err } return NewExoscaleProviderWithClient(client, env, zone, dryRun, opts...), nil } // NewExoscaleProviderWithClient returns ExoscaleProvider DNS provider interface implementation (Client provided) func NewExoscaleProviderWithClient(client EgoscaleClientI, env, zone string, dryRun bool, opts ...ExoscaleOption) *ExoscaleProvider { ep := &ExoscaleProvider{ filter: &zoneFilter{}, OnApplyChanges: func(_ *plan.Changes) {}, domain: endpoint.NewDomainFilter([]string{""}), client: client, apiEnv: env, apiZone: zone, dryRun: dryRun, } for _, opt := range opts { opt(ep) } return ep } func (ep *ExoscaleProvider) getZones(ctx context.Context) (map[string]string, error) { ctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone)) domains, err := ep.client.ListDNSDomains(ctx, ep.apiZone) if err != nil { return nil, err } zones := map[string]string{} for _, domain := range domains { zones[*domain.ID] = *domain.UnicodeName } return zones, nil } // ApplyChanges simply modifies DNS via exoscale API func (ep *ExoscaleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { ep.OnApplyChanges(changes) if ep.dryRun { log.Infof("Will NOT delete these records: %+v", changes.Delete) log.Infof("Will NOT create these records: %+v", changes.Create) log.Infof("Will NOT update these records: %+v", merge(changes.UpdateOld, changes.UpdateNew)) return nil } ctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone)) zones, err := ep.getZones(ctx) if err != nil { return err } for _, epoint := range changes.Create { if !ep.domain.Match(epoint.DNSName) { continue } zoneID, name := ep.filter.EndpointZoneID(epoint, zones) if zoneID == "" { continue } // API does not accept 0 as default TTL but wants nil pointer instead var ttl *int64 if epoint.RecordTTL != 0 { t := int64(epoint.RecordTTL) ttl = &t } record := egoscale.DNSDomainRecord{ Name: &name, Type: &epoint.RecordType, TTL: ttl, Content: &epoint.Targets[0], } _, err := ep.client.CreateDNSDomainRecord(ctx, ep.apiZone, zoneID, &record) if err != nil { return err } } for _, epoint := range changes.UpdateNew { if !ep.domain.Match(epoint.DNSName) { continue } zoneID, name := ep.filter.EndpointZoneID(epoint, zones) if zoneID == "" { continue } records, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, zoneID) if err != nil { return err } for _, record := range records { if *record.Name != name { continue } record.Type = &epoint.RecordType record.Content = &epoint.Targets[0] if epoint.RecordTTL != 0 { ttl := int64(epoint.RecordTTL) record.TTL = &ttl } err = ep.client.UpdateDNSDomainRecord(ctx, ep.apiZone, zoneID, &record) if err != nil { return err } break } } for _, epoint := range changes.UpdateOld { // Since Exoscale "Patches", we've ignored UpdateOld // We leave this logging here for information log.Debugf("UPDATE-OLD (ignored) for epoint: %+v", epoint) } for _, epoint := range changes.Delete { if !ep.domain.Match(epoint.DNSName) { continue } zoneID, name := ep.filter.EndpointZoneID(epoint, zones) if zoneID == "" { continue } records, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, zoneID) if err != nil { return err } for _, record := range records { if *record.Name != name { continue } err = ep.client.DeleteDNSDomainRecord(ctx, ep.apiZone, zoneID, &egoscale.DNSDomainRecord{ID: record.ID}) if err != nil { return err } break } } return nil } // Records returns the list of endpoints func (ep *ExoscaleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { ctx = exoapi.WithEndpoint(ctx, exoapi.NewReqEndpoint(ep.apiEnv, ep.apiZone)) endpoints := make([]*endpoint.Endpoint, 0) domains, err := ep.client.ListDNSDomains(ctx, ep.apiZone) if err != nil { return nil, err } for _, domain := range domains { records, err := ep.client.ListDNSDomainRecords(ctx, ep.apiZone, *domain.ID) if err != nil { return nil, err } for _, record := range records { if *record.Type != endpoint.RecordTypeA && *record.Type != endpoint.RecordTypeCNAME && *record.Type != endpoint.RecordTypeTXT { continue } e := endpoint.NewEndpointWithTTL((*record.Name)+"."+(*domain.UnicodeName), *record.Type, endpoint.TTL(*record.TTL), *record.Content) endpoints = append(endpoints, e) } } log.Infof("called Records() with %d items", len(endpoints)) return endpoints, nil } // ExoscaleWithDomain modifies the domain on which dns zones are filtered func ExoscaleWithDomain(domainFilter *endpoint.DomainFilter) ExoscaleOption { return func(p *ExoscaleProvider) { p.domain = domainFilter } } // ExoscaleWithLogging injects logging when ApplyChanges is called func ExoscaleWithLogging() ExoscaleOption { return func(p *ExoscaleProvider) { p.OnApplyChanges = func(changes *plan.Changes) { for _, v := range changes.Create { log.Infof("CREATE: %v", v) } for _, v := range changes.UpdateOld { log.Infof("UPDATE (old): %v", v) } for _, v := range changes.UpdateNew { log.Infof("UPDATE (new): %v", v) } for _, v := range changes.Delete { log.Infof("DELETE: %v", v) } } } } type zoneFilter struct { domain string } // Zones filters map[zoneID]zoneName for names having f.domain as suffix func (f *zoneFilter) Zones(zones map[string]string) map[string]string { result := map[string]string{} for zoneID, zoneName := range zones { if strings.HasSuffix(zoneName, f.domain) { result[zoneID] = zoneName } } return result } // EndpointZoneID determines zoneID for endpoint from map[zoneID]zoneName by taking longest suffix zoneName match in endpoint DNSName // returns empty string if no matches are found func (f *zoneFilter) EndpointZoneID(endpoint *endpoint.Endpoint, zones map[string]string) (string, string) { var matchZoneID, matchZoneName, name string for zoneID, zoneName := range zones { if strings.HasSuffix(endpoint.DNSName, "."+zoneName) && len(zoneName) > len(matchZoneName) { matchZoneName = zoneName matchZoneID = zoneID name = strings.TrimSuffix(endpoint.DNSName, "."+zoneName) } } return matchZoneID, name } func merge(updateOld, updateNew []*endpoint.Endpoint) []*endpoint.Endpoint { findMatch := func(template *endpoint.Endpoint) *endpoint.Endpoint { for _, record := range updateNew { if template.DNSName == record.DNSName && template.RecordType == record.RecordType { return record } } return nil } var result []*endpoint.Endpoint for _, old := range updateOld { matchingNew := findMatch(old) if matchingNew == nil { // no match shouldn't happen continue } if !matchingNew.Targets.Same(old.Targets) { // new target: always update, TTL will be overwritten too if necessary result = append(result, matchingNew) continue } if matchingNew.RecordTTL != 0 && matchingNew.RecordTTL != old.RecordTTL { // same target, but new non-zero TTL set in k8s, must update // probably would happen only if there is a bug in the code calling the provider result = append(result, matchingNew) } } return result } ================================================ FILE: provider/exoscale/exoscale_test.go ================================================ /* Copyright 2017 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 exoscale import ( "context" "testing" egoscale "github.com/exoscale/egoscale/v2" "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "github.com/google/uuid" ) type createRecordExoscale struct { domainID string record *egoscale.DNSDomainRecord } type deleteRecordExoscale struct { domainID string recordID string } type updateRecordExoscale struct { domainID string record *egoscale.DNSDomainRecord } var ( createExoscale []createRecordExoscale deleteExoscale []deleteRecordExoscale updateExoscale []updateRecordExoscale ) var defaultTTL int64 = 3600 var domainIDs = []string{uuid.New().String(), uuid.New().String(), uuid.New().String(), uuid.New().String()} var groups = map[string][]egoscale.DNSDomainRecord{ domainIDs[0]: { {ID: strPtr(uuid.New().String()), Name: strPtr("v1"), Type: strPtr("TXT"), Content: strPtr("test"), TTL: &defaultTTL}, {ID: strPtr(uuid.New().String()), Name: strPtr("v2"), Type: strPtr("CNAME"), Content: strPtr("test"), TTL: &defaultTTL}, }, domainIDs[1]: { {ID: strPtr(uuid.New().String()), Name: strPtr("v2"), Type: strPtr("A"), Content: strPtr("test"), TTL: &defaultTTL}, {ID: strPtr(uuid.New().String()), Name: strPtr("v3"), Type: strPtr("ALIAS"), Content: strPtr("test"), TTL: &defaultTTL}, }, domainIDs[2]: { {ID: strPtr(uuid.New().String()), Name: strPtr("v1"), Type: strPtr("TXT"), Content: strPtr("test"), TTL: &defaultTTL}, }, domainIDs[3]: { {ID: strPtr(uuid.New().String()), Name: strPtr("v4"), Type: strPtr("ALIAS"), Content: strPtr("test"), TTL: &defaultTTL}, }, } func strPtr(s string) *string { return &s } type ExoscaleClientStub struct{} func NewExoscaleClientStub() EgoscaleClientI { ep := &ExoscaleClientStub{} return ep } func (ep *ExoscaleClientStub) ListDNSDomains(_ context.Context, _ string) ([]egoscale.DNSDomain, error) { domains := []egoscale.DNSDomain{ {ID: &domainIDs[0], UnicodeName: strPtr("foo.com")}, {ID: &domainIDs[1], UnicodeName: strPtr("bar.com")}, } return domains, nil } func (ep *ExoscaleClientStub) ListDNSDomainRecords(_ context.Context, _, domainID string) ([]egoscale.DNSDomainRecord, error) { return groups[domainID], nil } func (ep *ExoscaleClientStub) CreateDNSDomainRecord(_ context.Context, _, domainID string, record *egoscale.DNSDomainRecord) (*egoscale.DNSDomainRecord, error) { createExoscale = append(createExoscale, createRecordExoscale{domainID: domainID, record: record}) return record, nil } func (ep *ExoscaleClientStub) DeleteDNSDomainRecord(_ context.Context, _, domainID string, record *egoscale.DNSDomainRecord) error { deleteExoscale = append(deleteExoscale, deleteRecordExoscale{domainID: domainID, recordID: *record.ID}) return nil } func (ep *ExoscaleClientStub) UpdateDNSDomainRecord(_ context.Context, _, domainID string, record *egoscale.DNSDomainRecord) error { updateExoscale = append(updateExoscale, updateRecordExoscale{domainID: domainID, record: record}) return nil } func contains(arr []*endpoint.Endpoint, name string) bool { for _, a := range arr { if a.DNSName == name { return true } } return false } func TestExoscaleGetRecords(t *testing.T) { provider := NewExoscaleProviderWithClient(NewExoscaleClientStub(), "", "", false) recs, err := provider.Records(t.Context()) if err == nil { assert.Len(t, recs, 3) assert.True(t, contains(recs, "v1.foo.com")) assert.True(t, contains(recs, "v2.bar.com")) assert.True(t, contains(recs, "v2.foo.com")) assert.False(t, contains(recs, "v3.bar.com")) assert.False(t, contains(recs, "v1.foobar.com")) } else { assert.Error(t, err) } } func TestExoscaleApplyChanges(t *testing.T) { provider := NewExoscaleProviderWithClient(NewExoscaleClientStub(), "", "", false) plan := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{""}, }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{""}, }, }, Delete: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{""}, }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{""}, }, }, UpdateOld: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{""}, }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{""}, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{""}, }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{""}, }, }, } createExoscale = make([]createRecordExoscale, 0) deleteExoscale = make([]deleteRecordExoscale, 0) provider.ApplyChanges(t.Context(), plan) assert.Len(t, createExoscale, 1) assert.Equal(t, domainIDs[0], createExoscale[0].domainID) assert.Equal(t, "v1", *createExoscale[0].record.Name) assert.Len(t, deleteExoscale, 1) assert.Equal(t, domainIDs[0], deleteExoscale[0].domainID) assert.Equal(t, *groups[domainIDs[0]][0].ID, deleteExoscale[0].recordID) assert.Len(t, updateExoscale, 1) assert.Equal(t, domainIDs[0], updateExoscale[0].domainID) assert.Equal(t, *groups[domainIDs[0]][0].ID, *updateExoscale[0].record.ID) } func TestExoscaleMerge_NoUpdateOnTTL0Changes(t *testing.T) { updateOld := []*endpoint.Endpoint{ { DNSName: "name1", Targets: endpoint.Targets{"target1"}, RecordTTL: endpoint.TTL(1), RecordType: endpoint.RecordTypeA, }, { DNSName: "name2", Targets: endpoint.Targets{"target2"}, RecordTTL: endpoint.TTL(1), RecordType: endpoint.RecordTypeA, }, } updateNew := []*endpoint.Endpoint{ { DNSName: "name1", Targets: endpoint.Targets{"target1"}, RecordTTL: endpoint.TTL(0), RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "name2", Targets: endpoint.Targets{"target2"}, RecordTTL: endpoint.TTL(0), RecordType: endpoint.RecordTypeCNAME, }, } assert.Empty(t, merge(updateOld, updateNew)) } func TestExoscaleMerge_UpdateOnTTLChanges(t *testing.T) { updateOld := []*endpoint.Endpoint{ { DNSName: "name1", Targets: endpoint.Targets{"target1"}, RecordTTL: endpoint.TTL(1), RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "name2", Targets: endpoint.Targets{"target2"}, RecordTTL: endpoint.TTL(1), RecordType: endpoint.RecordTypeCNAME, }, } updateNew := []*endpoint.Endpoint{ { DNSName: "name1", Targets: endpoint.Targets{"target1"}, RecordTTL: endpoint.TTL(77), RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "name2", Targets: endpoint.Targets{"target2"}, RecordTTL: endpoint.TTL(10), RecordType: endpoint.RecordTypeCNAME, }, } merged := merge(updateOld, updateNew) assert.Len(t, merged, 2) assert.Equal(t, "name1", merged[0].DNSName) } func TestExoscaleMerge_AlwaysUpdateTarget(t *testing.T) { updateOld := []*endpoint.Endpoint{ { DNSName: "name1", Targets: endpoint.Targets{"target1"}, RecordTTL: endpoint.TTL(1), RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "name2", Targets: endpoint.Targets{"target2"}, RecordTTL: endpoint.TTL(1), RecordType: endpoint.RecordTypeCNAME, }, } updateNew := []*endpoint.Endpoint{ { DNSName: "name1", Targets: endpoint.Targets{"target1-changed"}, RecordTTL: endpoint.TTL(0), RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "name2", Targets: endpoint.Targets{"target2"}, RecordTTL: endpoint.TTL(0), RecordType: endpoint.RecordTypeCNAME, }, } merged := merge(updateOld, updateNew) assert.Len(t, merged, 1) assert.Equal(t, "target1-changed", merged[0].Targets[0]) } func TestExoscaleMerge_NoUpdateIfTTLUnchanged(t *testing.T) { updateOld := []*endpoint.Endpoint{ { DNSName: "name1", Targets: endpoint.Targets{"target1"}, RecordTTL: endpoint.TTL(55), RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "name2", Targets: endpoint.Targets{"target2"}, RecordTTL: endpoint.TTL(55), RecordType: endpoint.RecordTypeCNAME, }, } updateNew := []*endpoint.Endpoint{ { DNSName: "name1", Targets: endpoint.Targets{"target1"}, RecordTTL: endpoint.TTL(55), RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "name2", Targets: endpoint.Targets{"target2"}, RecordTTL: endpoint.TTL(55), RecordType: endpoint.RecordTypeCNAME, }, } merged := merge(updateOld, updateNew) assert.Empty(t, merged) } func TestZones(t *testing.T) { tests := []struct { name string domain string input map[string]string expected map[string]string }{ { name: "single matching zone", domain: "example.com", input: map[string]string{ "1": "example.com", }, expected: map[string]string{ "1": "example.com", }, }, { name: "non matching zone", domain: "example.com", input: map[string]string{ "1": "other.com", }, expected: map[string]string{}, }, { name: "multiple zones mixed match", domain: "example.com", input: map[string]string{ "1": "example.com", "2": "sub.example.com", "3": "other.com", }, expected: map[string]string{ "1": "example.com", "2": "sub.example.com", }, }, { name: "empty input", domain: "example.com", input: map[string]string{}, expected: map[string]string{}, }, { name: "empty domain matches all", domain: "", input: map[string]string{ "1": "example.com", "2": "other.com", }, expected: map[string]string{ "1": "example.com", "2": "other.com", }, }, { name: "suffix must be exact", domain: "ample.com", input: map[string]string{ "1": "example.com", "2": "sample.com", }, expected: map[string]string{ "1": "example.com", "2": "sample.com", }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { zoneFilter := zoneFilter{ domain: test.domain, } result := zoneFilter.Zones(test.input) assert.Equal(t, test.expected, result) }) } } func TestExoscaleWithDomain_SetsDomain(t *testing.T) { tests := []struct { name string domainFilter []string }{ { name: "domain filter", domainFilter: []string{"example.com", "apple.xyz"}, }, } for _, test := range tests { t.Run(test.name, func(_ *testing.T) { p := &ExoscaleProvider{} df := endpoint.NewDomainFilter(test.domainFilter) ExoscaleWithDomain(df)(p) }) } } func TestInMemoryWithLogging_LogsChanges(t *testing.T) { t.Run("exoscaleWithlogging", func(t *testing.T) { logger, hook := test.NewNullLogger() log.SetFormatter(logger.Formatter) log.SetLevel(log.InfoLevel) log.AddHook(hook) p := &ExoscaleProvider{} ExoscaleWithLogging()(p) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "create.example.com", RecordType: "A"}, }, UpdateOld: []*endpoint.Endpoint{ {DNSName: "old.example.com", RecordType: "A"}, }, UpdateNew: []*endpoint.Endpoint{ {DNSName: "new.example.com", RecordType: "A"}, }, Delete: []*endpoint.Endpoint{ {DNSName: "delete.example.com", RecordType: "A"}, }, } p.OnApplyChanges(changes) entries := hook.AllEntries() assert.Contains(t, entries[0].Message, "CREATE") assert.Contains(t, entries[1].Message, "UPDATE (old)") assert.Contains(t, entries[2].Message, "UPDATE (new)") assert.Contains(t, entries[3].Message, "DELETE") }) } ================================================ FILE: provider/factory/provider.go ================================================ /* 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 factory import ( "context" "fmt" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/provider/akamai" "sigs.k8s.io/external-dns/provider/alibabacloud" "sigs.k8s.io/external-dns/provider/aws" "sigs.k8s.io/external-dns/provider/awssd" "sigs.k8s.io/external-dns/provider/azure" "sigs.k8s.io/external-dns/provider/civo" "sigs.k8s.io/external-dns/provider/cloudflare" "sigs.k8s.io/external-dns/provider/coredns" "sigs.k8s.io/external-dns/provider/dnsimple" "sigs.k8s.io/external-dns/provider/exoscale" "sigs.k8s.io/external-dns/provider/gandi" "sigs.k8s.io/external-dns/provider/godaddy" "sigs.k8s.io/external-dns/provider/google" "sigs.k8s.io/external-dns/provider/inmemory" "sigs.k8s.io/external-dns/provider/linode" "sigs.k8s.io/external-dns/provider/ns1" "sigs.k8s.io/external-dns/provider/oci" "sigs.k8s.io/external-dns/provider/ovh" "sigs.k8s.io/external-dns/provider/pdns" "sigs.k8s.io/external-dns/provider/pihole" "sigs.k8s.io/external-dns/provider/plural" "sigs.k8s.io/external-dns/provider/rfc2136" "sigs.k8s.io/external-dns/provider/scaleway" "sigs.k8s.io/external-dns/provider/transip" "sigs.k8s.io/external-dns/provider/webhook" ) // ProviderConstructor is a function that creates a provider from configuration. type ProviderConstructor func( ctx context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter, ) (provider.Provider, error) // Select creates a provider based on the given configuration. func Select( ctx context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { constructor, ok := providers(cfg.Provider) if !ok { return nil, fmt.Errorf("unknown dns provider: %s", cfg.Provider) } p, err := constructor(ctx, cfg, domainFilter) if err != nil { return nil, err } if p != nil && cfg.ProviderCacheTime > 0 { return provider.NewCachedProvider(p, cfg.ProviderCacheTime), nil } return p, nil } // providers looks up the constructor for the named provider. func providers(selector string) (ProviderConstructor, bool) { m := map[string]ProviderConstructor{ externaldns.ProviderAkamai: akamai.New, externaldns.ProviderAlibabaCloud: alibabacloud.New, externaldns.ProviderAWS: aws.New, externaldns.ProviderAWSSD: awssd.New, externaldns.ProviderAzure: azure.New, externaldns.ProviderAzureDNS: azure.New, externaldns.ProviderAzurePrivate: azure.NewPrivate, externaldns.ProviderCivo: civo.New, externaldns.ProviderCloudflare: cloudflare.New, externaldns.ProviderCoreDNS: coredns.New, externaldns.ProviderSkyDNS: coredns.New, externaldns.ProviderDNSimple: dnsimple.New, externaldns.ProviderExoscale: exoscale.New, externaldns.ProviderGandi: gandi.New, externaldns.ProviderGoDaddy: godaddy.New, externaldns.ProviderGoogle: google.New, externaldns.ProviderInMemory: inmemory.New, externaldns.ProviderLinode: linode.New, externaldns.ProviderNS1: ns1.New, externaldns.ProviderOCI: oci.New, externaldns.ProviderOVH: ovh.New, externaldns.ProviderPDNS: pdns.New, externaldns.ProviderPihole: pihole.New, externaldns.ProviderPlural: plural.New, externaldns.ProviderRFC2136: rfc2136.New, externaldns.ProviderScaleway: scaleway.New, externaldns.ProviderTransip: transip.New, externaldns.ProviderWebhook: webhook.New, } c, ok := m[selector] return c, ok } ================================================ FILE: provider/factory/provider_test.go ================================================ /* Copyright 2026 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 factory import ( "net/http" "net/http/httptest" "reflect" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) func TestSelectProvider(t *testing.T) { tests := []struct { name string cfg *externaldns.Config expectedType string expectedError string }{ { name: "aws provider", cfg: &externaldns.Config{ Provider: externaldns.ProviderAWS, }, expectedType: "*aws.AWSProvider", }, { name: "rfc2136 provider", cfg: &externaldns.Config{ Provider: externaldns.ProviderRFC2136, RFC2136TSIGSecretAlg: "hmac-sha256", }, expectedType: "*rfc2136.rfc2136Provider", }, { name: "gandi provider", cfg: &externaldns.Config{ Provider: externaldns.ProviderGandi, }, expectedError: "no environment variable GANDI_KEY or GANDI_PAT provided", }, { name: "inmemory provider", cfg: &externaldns.Config{ Provider: externaldns.ProviderInMemory, }, expectedType: "*inmemory.InMemoryProvider", }, { name: "oci provider instance principal without compartment OCID", cfg: &externaldns.Config{ Provider: externaldns.ProviderOCI, OCIAuthInstancePrincipal: true, OCICompartmentOCID: "", }, expectedError: "instance principal authentication requested, but no compartment OCID provided", }, { name: "oci provider without config file", cfg: &externaldns.Config{ Provider: externaldns.ProviderOCI, OCIConfigFile: "", }, expectedError: "reading OCI config file", }, { name: "coredns provider", cfg: &externaldns.Config{ Provider: externaldns.ProviderCoreDNS, }, expectedType: "coredns.coreDNSProvider", }, { name: "pihole provider", cfg: &externaldns.Config{ Provider: externaldns.ProviderPihole, PiholeApiVersion: "6", PiholeServer: "http://localhost:8080", }, expectedType: "*pihole.PiholeProvider", }, { name: "dnsimple provider", cfg: &externaldns.Config{ Provider: externaldns.ProviderDNSimple, }, expectedError: "no dnsimple oauth token provided", }, { name: "unknown provider", cfg: &externaldns.Config{ Provider: "unknown", }, expectedError: "unknown dns provider: unknown", }, { name: "inmemory cached provider", cfg: &externaldns.Config{ Provider: externaldns.ProviderInMemory, ProviderCacheTime: 10 * time.Millisecond, }, expectedType: "*provider.CachedProvider", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { domainFilter := endpoint.NewDomainFilter([]string{"example.com"}) p, err := Select(t.Context(), tt.cfg, domainFilter) if tt.expectedError != "" { assert.Error(t, err) assert.ErrorContains(t, err, tt.expectedError) } else { require.NoError(t, err) require.NotNil(t, p) assert.Contains(t, reflect.TypeOf(p).String(), tt.expectedType) } }) } } func TestKnownProviders(t *testing.T) { names := make([]string, 0, len(externaldns.ProviderNames)) for _, name := range externaldns.ProviderNames { t.Run(name, func(t *testing.T) { names = append(names, name) _, ok := providers(name) assert.True(t, ok, "expected provider %s to be registered", name) }) } assert.ElementsMatch(t, externaldns.ProviderNames, names) } func TestSelectProvider_Webhook(t *testing.T) { // Stand up a minimal HTTP server that returns a valid negotiation response. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/external.dns.webhook+json;version=1") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{}`)) })) defer srv.Close() cfg := &externaldns.Config{ Provider: externaldns.ProviderWebhook, WebhookProviderURL: srv.URL, } p, err := Select(t.Context(), cfg, nil) require.NoError(t, err) require.NotNil(t, p) } ================================================ FILE: provider/fakes/provider.go ================================================ /* 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 fakes import ( "context" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) type MockProvider struct { RecordsErr error ApplyChangesErr error } func (m *MockProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { return nil, m.RecordsErr } func (m *MockProvider) ApplyChanges(_ context.Context, _ *plan.Changes) error { return m.ApplyChangesErr } func (m *MockProvider) AdjustEndpoints(eps []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return eps, nil } func (m *MockProvider) GetDomainFilter() endpoint.DomainFilterInterface { return &endpoint.DomainFilter{} } ================================================ FILE: provider/gandi/client.go ================================================ /* Copyright 2021 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 gandi import ( "github.com/go-gandi/go-gandi/domain" "github.com/go-gandi/go-gandi/livedns" ) type DomainClientAdapter interface { ListDomains() ([]domain.ListResponse, error) } type domainClient struct { Client *domain.Domain } func (p *domainClient) ListDomains() ([]domain.ListResponse, error) { return p.Client.ListDomains() } func NewDomainClient(client *domain.Domain) DomainClientAdapter { return &domainClient{client} } // standardResponse copied from go-gandi/internal/gandi.go type standardResponse struct { Code int `json:"code,omitempty"` Message string `json:"message,omitempty"` UUID string `json:"uuid,omitempty"` Object string `json:"object,omitempty"` Cause string `json:"cause,omitempty"` Status string `json:"status,omitempty"` Errors []standardError `json:"errors,omitempty"` } // standardError copied from go-gandi/internal/gandi.go type standardError struct { Location string `json:"location"` Name string `json:"name"` Description string `json:"description"` } type LiveDNSClientAdapter interface { GetDomainRecords(fqdn string) (records []livedns.DomainRecord, err error) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) DeleteDomainRecord(fqdn, name, recordtype string) (err error) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) } type LiveDNSClient struct { Client *livedns.LiveDNS } func NewLiveDNSClient(client *livedns.LiveDNS) LiveDNSClientAdapter { return &LiveDNSClient{client} } func (p *LiveDNSClient) GetDomainRecords(fqdn string) ([]livedns.DomainRecord, error) { return p.Client.GetDomainRecords(fqdn) } func (p *LiveDNSClient) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) { res, err := p.Client.CreateDomainRecord(fqdn, name, recordtype, ttl, values) if err != nil { return standardResponse{}, err } // response needs to be copied as the Standard* structs are internal var errors []standardError for _, e := range res.Errors { errors = append(errors, standardError(e)) } return standardResponse{ Code: res.Code, Message: res.Message, UUID: res.UUID, Object: res.Object, Cause: res.Cause, Status: res.Status, Errors: errors, }, err } func (p *LiveDNSClient) DeleteDomainRecord(fqdn, name, recordtype string) error { return p.Client.DeleteDomainRecord(fqdn, name, recordtype) } func (p *LiveDNSClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) { res, err := p.Client.UpdateDomainRecordByNameAndType(fqdn, name, recordtype, ttl, values) if err != nil { return standardResponse{}, err } // response needs to be copied as the Standard* structs are internal var errors []standardError for _, e := range res.Errors { errors = append(errors, standardError(e)) } return standardResponse{ Code: res.Code, Message: res.Message, UUID: res.UUID, Object: res.Object, Cause: res.Cause, Status: res.Status, Errors: errors, }, err } ================================================ FILE: provider/gandi/gandi.go ================================================ /* Copyright 2021 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 gandi import ( "context" "errors" "os" "strings" "github.com/go-gandi/go-gandi" "github.com/go-gandi/go-gandi/config" "github.com/go-gandi/go-gandi/livedns" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( gandiCreate = "CREATE" gandiDelete = "DELETE" gandiUpdate = "UPDATE" defaultTTL = 600 gandiLiveDNSProvider = "livedns" ) type GandiChanges struct { Action string ZoneName string Record livedns.DomainRecord } type GandiProvider struct { provider.BaseProvider LiveDNSClient LiveDNSClientAdapter DomainClient DomainClientAdapter domainFilter *endpoint.DomainFilter DryRun bool } func newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*GandiProvider, error) { key, ok_key := os.LookupEnv("GANDI_KEY") pat, ok_pat := os.LookupEnv("GANDI_PAT") if !ok_key && !ok_pat { return nil, errors.New("no environment variable GANDI_KEY or GANDI_PAT provided") } if ok_key { log.Warning("Usage of GANDI_KEY (API Key) is deprecated. Please consider creating a Personal Access Token (PAT) instead, see https://api.gandi.net/docs/authentication/") } sharingID, _ := os.LookupEnv("GANDI_SHARING_ID") g := config.Config{ APIKey: key, PersonalAccessToken: pat, SharingID: sharingID, Debug: false, // dry-run doesn't work but it won't hurt passing the flag DryRun: dryRun, } liveDNSClient := gandi.NewLiveDNSClient(g) domainClient := gandi.NewDomainClient(g) gandiProvider := &GandiProvider{ LiveDNSClient: NewLiveDNSClient(liveDNSClient), DomainClient: NewDomainClient(domainClient), domainFilter: domainFilter, DryRun: dryRun, } return gandiProvider, nil } // New creates a Gandi provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(domainFilter, cfg.DryRun) } func (p *GandiProvider) Zones() ([]string, error) { availableDomains, err := p.DomainClient.ListDomains() if err != nil { return nil, err } zones := []string{} for _, domain := range availableDomains { if !p.domainFilter.Match(domain.FQDN) { log.Debugf("Excluding domain %s by domain-filter", domain.FQDN) continue } if domain.NameServer.Current != gandiLiveDNSProvider { log.Debugf("Excluding domain %s, not configured for livedns", domain.FQDN) continue } zones = append(zones, domain.FQDN) } return zones, nil } func (p *GandiProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { liveDNSZones, err := p.Zones() if err != nil { return nil, err } endpoints := []*endpoint.Endpoint{} for _, zone := range liveDNSZones { records, err := p.LiveDNSClient.GetDomainRecords(zone) if err != nil { return nil, err } for _, r := range records { if provider.SupportedRecordType(r.RrsetType) { name := r.RrsetName + "." + zone if r.RrsetName == "@" { name = zone } for _, v := range r.RrsetValues { log.WithFields(log.Fields{ "record": r.RrsetName, "type": r.RrsetType, "value": v, "ttl": r.RrsetTTL, "zone": zone, }).Debug("Returning endpoint record") endpoints = append( endpoints, endpoint.NewEndpointWithTTL(name, r.RrsetType, endpoint.TTL(r.RrsetTTL), v), ) } } } } return endpoints, nil } func (p *GandiProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { combinedChanges := make([]*GandiChanges, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) combinedChanges = append(combinedChanges, p.newGandiChanges(gandiCreate, changes.Create)...) combinedChanges = append(combinedChanges, p.newGandiChanges(gandiUpdate, changes.UpdateNew)...) combinedChanges = append(combinedChanges, p.newGandiChanges(gandiDelete, changes.Delete)...) return p.submitChanges(ctx, combinedChanges) } func (p *GandiProvider) submitChanges(_ context.Context, changes []*GandiChanges) error { if len(changes) == 0 { log.Infof("All records are already up to date") return nil } liveDNSDomains, err := p.Zones() if err != nil { return err } zoneChanges := p.groupAndFilterByZone(liveDNSDomains, changes) for _, changes := range zoneChanges { for _, change := range changes { if change.Record.RrsetType == endpoint.RecordTypeCNAME && !strings.HasSuffix(change.Record.RrsetValues[0], ".") { change.Record.RrsetValues[0] += "." } // Prepare record name if change.Record.RrsetName == change.ZoneName { log.WithFields(log.Fields{ "record": change.Record.RrsetName, "type": change.Record.RrsetType, "value": change.Record.RrsetValues[0], "ttl": change.Record.RrsetTTL, "action": change.Action, "zone": change.ZoneName, }).Debugf("Converting record name: %s to apex domain (@)", change.Record.RrsetName) change.Record.RrsetName = "@" } else { change.Record.RrsetName = strings.TrimSuffix( change.Record.RrsetName, "."+change.ZoneName, ) } log.WithFields(log.Fields{ "record": change.Record.RrsetName, "type": change.Record.RrsetType, "value": change.Record.RrsetValues[0], "ttl": change.Record.RrsetTTL, "action": change.Action, "zone": change.ZoneName, }).Info("Changing record") if !p.DryRun { switch change.Action { case gandiCreate: answer, err := p.LiveDNSClient.CreateDomainRecord( change.ZoneName, change.Record.RrsetName, change.Record.RrsetType, change.Record.RrsetTTL, change.Record.RrsetValues, ) if err != nil { log.WithFields(log.Fields{ "Code": answer.Code, "Message": answer.Message, "Cause": answer.Cause, "Errors": answer.Errors, }).Warning("Create problem") return err } case gandiDelete: err := p.LiveDNSClient.DeleteDomainRecord(change.ZoneName, change.Record.RrsetName, change.Record.RrsetType) if err != nil { log.Warning("Delete problem") return err } case gandiUpdate: answer, err := p.LiveDNSClient.UpdateDomainRecordByNameAndType( change.ZoneName, change.Record.RrsetName, change.Record.RrsetType, change.Record.RrsetTTL, change.Record.RrsetValues, ) if err != nil { log.WithFields(log.Fields{ "Code": answer.Code, "Message": answer.Message, "Cause": answer.Cause, "Errors": answer.Errors, }).Warning("Update problem") return err } } } } } return nil } func (p *GandiProvider) newGandiChanges(action string, endpoints []*endpoint.Endpoint) []*GandiChanges { changes := make([]*GandiChanges, 0, len(endpoints)) ttl := defaultTTL for _, e := range endpoints { if e.RecordTTL.IsConfigured() { ttl = int(e.RecordTTL) } change := &GandiChanges{ Action: action, Record: livedns.DomainRecord{ RrsetType: e.RecordType, RrsetName: e.DNSName, RrsetValues: e.Targets, RrsetTTL: ttl, }, } changes = append(changes, change) } return changes } func (p *GandiProvider) groupAndFilterByZone(zones []string, changes []*GandiChanges) map[string][]*GandiChanges { change := make(map[string][]*GandiChanges) zoneNameID := provider.ZoneIDName{} for _, z := range zones { zoneNameID.Add(z, z) change[z] = []*GandiChanges{} } for _, c := range changes { zoneID, zoneName := zoneNameID.FindZone(c.Record.RrsetName) if zoneName == "" { log.Debugf("Skipping record %s because no hosted domain matching record DNS Name was detected", c.Record.RrsetName) continue } c.ZoneName = zoneName change[zoneID] = append(change[zoneID], c) } return change } ================================================ FILE: provider/gandi/gandi_test.go ================================================ /* Copyright 2021 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 gandi import ( "fmt" "os" "testing" "github.com/go-gandi/go-gandi/domain" "github.com/go-gandi/go-gandi/livedns" "github.com/maxatome/go-testdeep/td" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" ) type MockAction struct { Name string FQDN string Record livedns.DomainRecord } type mockGandiClient struct { Actions []MockAction FunctionToFail string `default:""` RecordsToReturn []livedns.DomainRecord } const ( domainUriPrefix = "https://api.gandi.net/v5/domain/domains/" exampleDotComUri = domainUriPrefix + "example.com" exampleDotNetUri = domainUriPrefix + "example.net" ) // Mock all methods func (m *mockGandiClient) GetDomainRecords(fqdn string) ([]livedns.DomainRecord, error) { m.Actions = append(m.Actions, MockAction{ Name: "GetDomainRecords", FQDN: fqdn, }) if m.FunctionToFail == "GetDomainRecords" { return nil, fmt.Errorf("injected error") } return m.RecordsToReturn, nil } func (m *mockGandiClient) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) { m.Actions = append(m.Actions, MockAction{ Name: "CreateDomainRecord", FQDN: fqdn, Record: livedns.DomainRecord{ RrsetType: recordtype, RrsetTTL: ttl, RrsetName: name, RrsetValues: values, }, }) if m.FunctionToFail == "CreateDomainRecord" { return standardResponse{}, fmt.Errorf("injected error") } return standardResponse{}, nil } func (m *mockGandiClient) DeleteDomainRecord(fqdn, name, recordtype string) error { m.Actions = append(m.Actions, MockAction{ Name: "DeleteDomainRecord", FQDN: fqdn, Record: livedns.DomainRecord{ RrsetType: recordtype, RrsetName: name, }, }) if m.FunctionToFail == "DeleteDomainRecord" { return fmt.Errorf("injected error") } return nil } func (m *mockGandiClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) { m.Actions = append(m.Actions, MockAction{ Name: "UpdateDomainRecordByNameAndType", FQDN: fqdn, Record: livedns.DomainRecord{ RrsetType: recordtype, RrsetTTL: ttl, RrsetName: name, RrsetValues: values, }, }) if m.FunctionToFail == "UpdateDomainRecordByNameAndType" { return standardResponse{}, fmt.Errorf("injected error") } return standardResponse{}, nil } func (m *mockGandiClient) ListDomains() ([]domain.ListResponse, error) { m.Actions = append(m.Actions, MockAction{ Name: "ListDomains", }) if m.FunctionToFail == "ListDomains" { return []domain.ListResponse{}, fmt.Errorf("injected error") } return []domain.ListResponse{ // Tests are using example.com { FQDN: "example.com", FQDNUnicode: "example.com", Href: exampleDotComUri, ID: "b3e9c271-1c29-4441-97d9-bc021a7ac7c3", NameServer: &domain.NameServerConfig{ Current: gandiLiveDNSProvider, }, TLD: "com", }, // example.net returns "other" as NameServer, so it is ignored { FQDN: "example.net", FQDNUnicode: "example.net", Href: exampleDotNetUri, ID: "dc78c1d8-6143-4edb-93bc-3a20d8bc3570", NameServer: &domain.NameServerConfig{ Current: "other", }, TLD: "net", }, }, nil } // Tests func TestNewProvider(t *testing.T) { t.Setenv("GANDI_KEY", "myGandiKey") provider, err := newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err != nil { t.Errorf("failed : %s", err) } assert.True(t, provider.DryRun) t.Setenv("GANDI_PAT", "myGandiPAT") provider, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err != nil { t.Errorf("failed : %s", err) } assert.True(t, provider.DryRun) _ = os.Unsetenv("GANDI_KEY") provider, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err != nil { t.Errorf("failed : %s", err) } assert.True(t, provider.DryRun) t.Setenv("GANDI_SHARING_ID", "aSharingId") provider, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), false) if err != nil { t.Errorf("failed : %s", err) } assert.False(t, provider.DryRun) _ = os.Unsetenv("GANDI_PAT") _, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err == nil { t.Errorf("expected to fail") } } func TestGandiProvider_RecordsReturnsCorrectEndpoints(t *testing.T) { mockedClient := &mockGandiClient{ RecordsToReturn: []livedns.DomainRecord{ { RrsetType: endpoint.RecordTypeCNAME, RrsetTTL: 600, RrsetName: "@", RrsetHref: exampleDotComUri + "/records/%40/A", RrsetValues: []string{"192.168.0.1"}, }, { RrsetType: endpoint.RecordTypeCNAME, RrsetTTL: 600, RrsetName: "www", RrsetHref: exampleDotComUri + "/records/www/CNAME", RrsetValues: []string{"lb.example.com"}, }, { RrsetType: endpoint.RecordTypeA, RrsetTTL: 600, RrsetName: "test", RrsetHref: exampleDotComUri + "/records/test/A", RrsetValues: []string{"192.168.0.2"}, }, }, } mockedProvider := &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } actualEndpoints, err := mockedProvider.Records(t.Context()) if err != nil { t.Errorf("should not fail, %s", err) } expectedEndpoints := []*endpoint.Endpoint{ { RecordType: endpoint.RecordTypeCNAME, DNSName: "example.com", Targets: endpoint.Targets{"192.168.0.1"}, RecordTTL: 600, }, { RecordType: endpoint.RecordTypeCNAME, DNSName: "www.example.com", Targets: endpoint.Targets{"lb.example.com"}, RecordTTL: 600, }, { RecordType: endpoint.RecordTypeA, DNSName: "test.example.com", Targets: endpoint.Targets{"192.168.0.2"}, RecordTTL: 600, }, } assert.Len(t, actualEndpoints, len(expectedEndpoints)) // we could use testutils.SameEndpoints (plural), but this makes it easier to identify which case is failing for i := range actualEndpoints { if !testutils.SameEndpoint(expectedEndpoints[i], actualEndpoints[i]) { t.Errorf("should be equal, expected:%v <> actual:%v", expectedEndpoints[i], actualEndpoints[i]) } } } func TestGandiProvider_RecordsOnFilteredDomainsShouldYieldNoEndpoints(t *testing.T) { mockedClient := &mockGandiClient{ RecordsToReturn: []livedns.DomainRecord{ { RrsetType: endpoint.RecordTypeCNAME, RrsetTTL: 600, RrsetName: "@", RrsetHref: exampleDotComUri + "/records/test/MX", RrsetValues: []string{"192.168.0.1"}, }, }, } mockedProvider := &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, domainFilter: endpoint.NewDomainFilterWithExclusions([]string{}, []string{"example.com"}), } endpoints, _ := mockedProvider.Records(t.Context()) assert.Empty(t, endpoints) } func TestGandiProvider_RecordsWithUnsupportedTypesAreNotReturned(t *testing.T) { mockedClient := &mockGandiClient{ RecordsToReturn: []livedns.DomainRecord{ { RrsetType: "MX", RrsetTTL: 360, RrsetName: "@", RrsetHref: exampleDotComUri + "/records/%40/A", RrsetValues: []string{"smtp.example.com"}, }, }, } mockedProvider := &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } endpoints, _ := mockedProvider.Records(t.Context()) assert.Empty(t, endpoints) } func TestGandiProvider_ApplyChangesMakesExpectedAPICalls(t *testing.T) { changes := &plan.Changes{} mockedClient := &mockGandiClient{} mockedProvider := &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } changes.Create = []*endpoint.Endpoint{ { DNSName: "test2.example.com", Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", RecordTTL: 666, }, } changes.UpdateNew = []*endpoint.Endpoint{ { DNSName: "test3.example.com", Targets: endpoint.Targets{"192.168.0.2"}, RecordType: "A", RecordTTL: 777, }, { DNSName: "example.com.example.com", Targets: endpoint.Targets{"lb-2.example.net"}, RecordType: "CNAME", RecordTTL: 777, }, } changes.Delete = []*endpoint.Endpoint{ { DNSName: "test4.example.com", Targets: endpoint.Targets{"192.168.0.3"}, RecordType: "A", }, } err := mockedProvider.ApplyChanges(t.Context(), changes) if err != nil { t.Errorf("should not fail, %s", err) } td.Cmp(t, mockedClient.Actions, []MockAction{ { Name: "ListDomains", }, { Name: "CreateDomainRecord", FQDN: "example.com", Record: livedns.DomainRecord{ RrsetType: endpoint.RecordTypeA, RrsetName: "test2", RrsetValues: []string{"192.168.0.1"}, RrsetTTL: 666, }, }, { Name: "UpdateDomainRecordByNameAndType", FQDN: "example.com", Record: livedns.DomainRecord{ RrsetType: endpoint.RecordTypeA, RrsetName: "test3", RrsetValues: []string{"192.168.0.2"}, RrsetTTL: 777, }, }, { Name: "UpdateDomainRecordByNameAndType", FQDN: "example.com", Record: livedns.DomainRecord{ RrsetType: endpoint.RecordTypeCNAME, RrsetName: "example.com", RrsetValues: []string{"lb-2.example.net."}, RrsetTTL: 777, }, }, { Name: "DeleteDomainRecord", FQDN: "example.com", Record: livedns.DomainRecord{ RrsetType: endpoint.RecordTypeA, RrsetName: "test4", }, }, }) } func TestGandiProvider_ApplyChangesRespectsDryRun(t *testing.T) { changes := &plan.Changes{} mockedClient := &mockGandiClient{} mockedProvider := &GandiProvider{ DryRun: true, DomainClient: mockedClient, LiveDNSClient: mockedClient, } changes.Create = []*endpoint.Endpoint{{DNSName: "test2.example.com", Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", RecordTTL: 666}} changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test3.example.com", Targets: endpoint.Targets{"192.168.0.2"}, RecordType: "A", RecordTTL: 777}} changes.Delete = []*endpoint.Endpoint{{DNSName: "test4.example.com", Targets: endpoint.Targets{"192.168.0.3"}, RecordType: "A"}} mockedProvider.ApplyChanges(t.Context(), changes) td.Cmp(t, mockedClient.Actions, []MockAction{ { Name: "ListDomains", }, }) } func TestGandiProvider_ApplyChangesWithEmptyResultDoesNothing(t *testing.T) { changes := &plan.Changes{} mockedClient := &mockGandiClient{} mockedProvider := &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } mockedProvider.ApplyChanges(t.Context(), changes) assert.Empty(t, mockedClient.Actions) } func TestGandiProvider_ApplyChangesWithUnknownDomainDoesNoUpdate(t *testing.T) { changes := &plan.Changes{} mockedClient := &mockGandiClient{} mockedProvider := &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } changes.Create = []*endpoint.Endpoint{ { DNSName: "test.example.net", Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", RecordTTL: 666, }, } mockedProvider.ApplyChanges(t.Context(), changes) td.Cmp(t, mockedClient.Actions, []MockAction{ { Name: "ListDomains", }, }) } func TestGandiProvider_ApplyChangesConvertsApexDomain(t *testing.T) { changes := &plan.Changes{} mockedClient := &mockGandiClient{} mockedProvider := &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } // Add a change where DNSName equals the zone name (apex domain) changes.Create = []*endpoint.Endpoint{ { DNSName: "example.com", // Matches the zone name Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", RecordTTL: 666, }, } err := mockedProvider.ApplyChanges(t.Context(), changes) if err != nil { t.Errorf("should not fail, %s", err) } td.Cmp(t, mockedClient.Actions, []MockAction{ { Name: "ListDomains", }, { Name: "CreateDomainRecord", FQDN: "example.com", Record: livedns.DomainRecord{ RrsetType: endpoint.RecordTypeA, RrsetName: "@", RrsetValues: []string{"192.168.0.1"}, RrsetTTL: 666, }, }, }) } func TestGandiProvider_FailingCases(t *testing.T) { changes := &plan.Changes{} changes.Create = []*endpoint.Endpoint{{DNSName: "test2.example.com", Targets: endpoint.Targets{"192.168.0.1"}, RecordType: "A", RecordTTL: 666}} changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test3.example.com", Targets: endpoint.Targets{"192.168.0.2"}, RecordType: "A", RecordTTL: 777}} changes.Delete = []*endpoint.Endpoint{{DNSName: "test4.example.com", Targets: endpoint.Targets{"192.168.0.3"}, RecordType: "A"}} // Failing ListDomains API call creates an error when calling Records mockedClient := &mockGandiClient{ FunctionToFail: "ListDomains", } mockedProvider := &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } _, err := mockedProvider.Records(t.Context()) if err == nil { t.Error("should have failed") } // Failing GetDomainRecords API call creates an error when calling Records mockedClient = &mockGandiClient{ FunctionToFail: "GetDomainRecords", } mockedProvider = &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } _, err = mockedProvider.Records(t.Context()) if err == nil { t.Error("should have failed") } // Failing ListDomains API call creates an error when calling ApplyChanges mockedClient = &mockGandiClient{ FunctionToFail: "ListDomains", } mockedProvider = &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } err = mockedProvider.ApplyChanges(t.Context(), changes) if err == nil { t.Error("should have failed") } // Failing CreateDomainRecord API call creates an error when calling ApplyChanges mockedClient = &mockGandiClient{ FunctionToFail: "CreateDomainRecord", } mockedProvider = &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } err = mockedProvider.ApplyChanges(t.Context(), changes) if err == nil { t.Error("should have failed") } // Failing DeleteDomainRecord API call creates an error when calling ApplyChanges mockedClient = &mockGandiClient{ FunctionToFail: "DeleteDomainRecord", } mockedProvider = &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } err = mockedProvider.ApplyChanges(t.Context(), changes) if err == nil { t.Error("should have failed") } // Failing UpdateDomainRecordByNameAndType API call creates an error when calling ApplyChanges mockedClient = &mockGandiClient{ FunctionToFail: "UpdateDomainRecordByNameAndType", } mockedProvider = &GandiProvider{ DomainClient: mockedClient, LiveDNSClient: mockedClient, } err = mockedProvider.ApplyChanges(t.Context(), changes) if err == nil { t.Error("should have failed") } } ================================================ FILE: provider/godaddy/client.go ================================================ /* Copyright 2020 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 godaddy import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "math/rand" "net/http" "strconv" "time" log "github.com/sirupsen/logrus" "golang.org/x/time/rate" "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) const ( ErrCodeQuotaExceeded = "QUOTA_EXCEEDED" // DefaultTimeout api requests after DefaultTimeout = 180 * time.Second ) // Errors var ( ErrAPIDown = errors.New("godaddy: the GoDaddy API is down") ) // APIError error type APIError struct { Code string Message string } func (err *APIError) Error() string { return fmt.Sprintf("Error %s: %q", err.Code, err.Message) } // Logger is the interface that should be implemented for loggers that wish to // log HTTP requests and HTTP responses. type Logger interface { // LogRequest logs an HTTP request. LogRequest(*http.Request) // LogResponse logs an HTTP response. LogResponse(*http.Response) } // Client represents a client to call the GoDaddy API type Client struct { // APIKey holds the Application key APIKey string // APISecret holds the Application secret key APISecret string // API endpoint APIEndPoint string // Client is the underlying HTTP client used to run the requests. It may be overloaded but a default one is instanciated in ``NewClient`` by default. Client *http.Client // GoDaddy limits to 60 requests per minute Ratelimiter *rate.Limiter // Logger is used to log HTTP requests and responses. Logger Logger Timeout time.Duration } // GDErrorField describe the error reason type GDErrorField struct { Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` Path string `json:"path,omitempty"` PathRelated string `json:"pathRelated,omitempty"` } // GDErrorResponse is the body response when an API call fails type GDErrorResponse struct { Code string `json:"code"` Fields []GDErrorField `json:"fields,omitempty"` Message string `json:"message,omitempty"` } func (r GDErrorResponse) String() string { if b, err := json.Marshal(r); err == nil { return string(b) } return "" } // NewClient represents a new client to call the API func NewClient(useOTE bool, apiKey, apiSecret string) (*Client, error) { var endpoint string if useOTE { endpoint = "https://api.ote-godaddy.com" } else { endpoint = "https://api.godaddy.com" } client := Client{ APIKey: apiKey, APISecret: apiSecret, APIEndPoint: endpoint, Client: &http.Client{}, // Add one token every second Ratelimiter: rate.NewLimiter(rate.Every(time.Second), 60), Timeout: DefaultTimeout, } // Get and check the configuration if err := client.validate(); err != nil { var apiErr *APIError // Quota Exceeded errors are limited to the endpoint being called. Other endpoints are not affected when we hit // the quota limit on the endpoint used for validation. We can safely ignore this error. // Quota limits on other endpoints will be logged by their respective calls. if ok := errors.As(err, &apiErr); ok && apiErr.Code != ErrCodeQuotaExceeded { return nil, err } } return &client, nil } // // Common request wrappers // // Get is a wrapper for the GET method func (c *Client) Get(url string, resType any) error { return c.CallAPI("GET", url, nil, resType) } // Patch is a wrapper for the PATCH method func (c *Client) Patch(url string, reqBody, resType any) error { return c.CallAPI("PATCH", url, reqBody, resType) } // Post is a wrapper for the POST method func (c *Client) Post(url string, reqBody, resType any) error { return c.CallAPI("POST", url, reqBody, resType) } // Put is a wrapper for the PUT method func (c *Client) Put(url string, reqBody, resType any) error { return c.CallAPI("PUT", url, reqBody, resType) } // Delete is a wrapper for the DELETE method func (c *Client) Delete(url string, resType any) error { return c.CallAPI("DELETE", url, nil, resType) } // GetWithContext is a wrapper for the GET method func (c *Client) GetWithContext(ctx context.Context, url string, resType any) error { return c.CallAPIWithContext(ctx, "GET", url, nil, resType) } // PatchWithContext is a wrapper for the PATCH method func (c *Client) PatchWithContext(ctx context.Context, url string, reqBody, resType any) error { return c.CallAPIWithContext(ctx, "PATCH", url, reqBody, resType) } // PostWithContext is a wrapper for the POST method func (c *Client) PostWithContext(ctx context.Context, url string, reqBody, resType any) error { return c.CallAPIWithContext(ctx, "POST", url, reqBody, resType) } // PutWithContext is a wrapper for the PUT method func (c *Client) PutWithContext(ctx context.Context, url string, reqBody, resType any) error { return c.CallAPIWithContext(ctx, "PUT", url, reqBody, resType) } // DeleteWithContext is a wrapper for the DELETE method func (c *Client) DeleteWithContext(ctx context.Context, url string, resType any) error { return c.CallAPIWithContext(ctx, "DELETE", url, nil, resType) } // NewRequest returns a new HTTP request func (c *Client) NewRequest(method, path string, reqBody any) (*http.Request, error) { var body []byte var err error if reqBody != nil { body, err = json.Marshal(reqBody) if err != nil { return nil, err } } target := fmt.Sprintf("%s%s", c.APIEndPoint, path) req, err := http.NewRequest(method, target, bytes.NewReader(body)) if err != nil { return nil, err } // Inject headers if body != nil { req.Header.Set("Content-Type", "application/json;charset=utf-8") } req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", c.APIKey, c.APISecret)) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", externaldns.UserAgent()) // Send the request with requested timeout c.Client.Timeout = c.Timeout return req, nil } // Do sends an HTTP request and returns an HTTP response func (c *Client) Do(req *http.Request) (*http.Response, error) { if c.Logger != nil { c.Logger.LogRequest(req) } c.Ratelimiter.Wait(req.Context()) resp, err := c.Client.Do(req) if err != nil { return nil, err } // In case of several clients behind NAT we still can hit rate limit for i := 1; i < 3 && resp != nil && resp.StatusCode == http.StatusTooManyRequests; i++ { retryAfter, err := strconv.ParseInt(resp.Header.Get("Retry-After"), 10, 0) if err != nil { log.Error("Rate-limited response did not contain a valid Retry-After header, quota likely exceeded") break } jitter := rand.Int63n(retryAfter) retryAfterSec := retryAfter + jitter/2 sleepTime := time.Duration(retryAfterSec) * time.Second time.Sleep(sleepTime) c.Ratelimiter.Wait(req.Context()) resp, err = c.Client.Do(req) if err != nil { return nil, fmt.Errorf("doing request after waiting for retry after: %w", err) } } if c.Logger != nil { c.Logger.LogResponse(resp) } return resp, nil } // CallAPI is the lowest level call helper. If needAuth is true, // inject authentication headers and sign the request. // // Request signature is a sha1 hash on following fields, joined by '+': // - applicationSecret (from Client instance) // - consumerKey (from Client instance) // - capitalized method (from arguments) // - full request url, including any query string argument // - full serialized request body // - server current time (takes time delta into account) // // Call will automatically assemble the target url from the endpoint // configured in the client instance and the path argument. If the reqBody // argument is not nil, it will also serialize it as json and inject // the required Content-Type header. // // If everything went fine, unmarshall response into resType and return nil // otherwise, return the error func (c *Client) CallAPI(method, path string, reqBody, resType any) error { return c.CallAPIWithContext(context.Background(), method, path, reqBody, resType) } // CallAPIWithContext is the lowest level call helper. If needAuth is true, // inject authentication headers and sign the request. // // Request signature is a sha1 hash on following fields, joined by '+': // - applicationSecret (from Client instance) // - consumerKey (from Client instance) // - capitalized method (from arguments) // - full request url, including any query string argument // - full serialized request body // - server current time (takes time delta into account) // // # Context is used by http.Client to handle context cancelation // // Call will automatically assemble the target url from the endpoint // configured in the client instance and the path argument. If the reqBody // argument is not nil, it will also serialize it as json and inject // the required Content-Type header. // // If everything went fine, unmarshall response into resType and return nil // otherwise, return the error func (c *Client) CallAPIWithContext(ctx context.Context, method, path string, reqBody, resType any) error { req, err := c.NewRequest(method, path, reqBody) if err != nil { return err } req = req.WithContext(ctx) response, err := c.Do(req) if err != nil { return err } return c.UnmarshalResponse(response, resType) } // UnmarshalResponse checks the response and unmarshals it into the response // type if needed Helper function, called from CallAPI func (c *Client) UnmarshalResponse(response *http.Response, resType any) error { // Read all the response body defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return err } // < 200 && >= 300 : API error if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { apiError := &APIError{ Code: fmt.Sprintf("HTTPStatus: %d", response.StatusCode), } if err = json.Unmarshal(body, apiError); err != nil { return err } return apiError } // Nothing to unmarshal if len(body) == 0 || resType == nil { return nil } return json.Unmarshal(body, &resType) } func (c *Client) validate() error { var response any if err := c.Get(domainsURI, response); err != nil { return err } return nil } ================================================ FILE: provider/godaddy/client_test.go ================================================ /* Copyright 2017 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 godaddy import ( "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "golang.org/x/time/rate" ) // Tests that func TestClient_DoWhenQuotaExceeded(t *testing.T) { assert := assert.New(t) // Mock server to return 429 with a JSON payload mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) _, err := w.Write([]byte(`{"code": "QUOTA_EXCEEDED", "message": "rate limit exceeded"}`)) if err != nil { t.Fatalf("Failed to write response: %v", err) } })) defer mockServer.Close() client := Client{ APIKey: "", APISecret: "", APIEndPoint: mockServer.URL, Client: &http.Client{}, // Add one token every second Ratelimiter: rate.NewLimiter(rate.Every(time.Second), 60), Timeout: DefaultTimeout, } req, err := client.NewRequest("GET", "/v1/domains/example.net/records", nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } resp, err := client.Do(req) assert.NoError(err, "A CODE_EXCEEDED response should not return an error") assert.Equal(http.StatusTooManyRequests, resp.StatusCode, "Expected a 429 response") respContents := GDErrorResponse{} err = client.UnmarshalResponse(resp, &respContents) if assert.Error(err) { var apiErr *APIError errors.As(err, &apiErr) assert.Equal("QUOTA_EXCEEDED", apiErr.Code) assert.Equal("rate limit exceeded", apiErr.Message) } } ================================================ FILE: provider/godaddy/godaddy.go ================================================ /* Copyright 2020 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 godaddy import ( "context" "encoding/json" "fmt" "slices" "strings" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( defaultTTL = 600 gdCreate = 0 gdReplace = 1 gdDelete = 2 domainsURI = "/v1/domains?statuses=ACTIVE,PENDING_DNS_ACTIVE" ) var actionNames = []string{ "create", "replace", "delete", } type gdClient interface { Patch(string, any, any) error Post(string, any, any) error Put(string, any, any) error Get(string, any) error Delete(string, any) error } // GDProvider declare GoDaddy provider type GDProvider struct { provider.BaseProvider domainFilter *endpoint.DomainFilter client gdClient ttl int64 DryRun bool } type gdEndpoint struct { endpoint *endpoint.Endpoint action int } type gdRecordField struct { Data string `json:"data"` Name string `json:"name"` TTL int64 `json:"ttl"` Type string `json:"type"` Port *int `json:"port,omitempty"` Priority *int `json:"priority,omitempty"` Weight *int64 `json:"weight,omitempty"` Protocol *string `json:"protocol,omitempty"` Service *string `json:"service,omitempty"` } type gdReplaceRecordField struct { Data string `json:"data"` TTL int64 `json:"ttl"` Port *int `json:"port,omitempty"` Priority *int `json:"priority,omitempty"` Weight *int64 `json:"weight,omitempty"` Protocol *string `json:"protocol,omitempty"` Service *string `json:"service,omitempty"` } type gdRecords struct { records []gdRecordField changed bool zone string } type gdZone struct { CreatedAt string Domain string DomainID int64 ExpirationProtected bool Expires string ExposeWhois bool HoldRegistrar bool Locked bool NameServers *[]string Privacy bool RenewAuto bool RenewDeadline string Renewable bool Status string TransferProtected bool } type gdZoneIDName map[string]*gdRecords func (z gdZoneIDName) add(zoneID string, zoneRecord *gdRecords) { z[zoneID] = zoneRecord } func (z gdZoneIDName) findZoneRecord(hostname string) (string, *gdRecords) { var suitableZoneID string var suitableZoneRecord *gdRecords for zoneID, zoneRecord := range z { if hostname == zoneRecord.zone || strings.HasSuffix(hostname, "."+zoneRecord.zone) { if suitableZoneRecord == nil || len(zoneRecord.zone) > len(suitableZoneRecord.zone) { suitableZoneID = zoneID suitableZoneRecord = zoneRecord } } } return suitableZoneID, suitableZoneRecord } // newProvider initializes a new GoDaddy DNS based Provider. func newProvider(domainFilter *endpoint.DomainFilter, ttl int64, apiKey, apiSecret string, useOTE, dryRun bool) (*GDProvider, error) { client, err := NewClient(useOTE, apiKey, apiSecret) if err != nil { return nil, err } return &GDProvider{ client: client, domainFilter: domainFilter, ttl: maxOf(defaultTTL, ttl), DryRun: dryRun, }, nil } // New creates a GoDaddy provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(domainFilter, cfg.GoDaddyTTL, cfg.GoDaddyAPIKey, cfg.GoDaddySecretKey, cfg.GoDaddyOTE, cfg.DryRun) } func (p *GDProvider) zones() ([]string, error) { zones := []gdZone{} filteredZones := []string{} if err := p.client.Get(domainsURI, &zones); err != nil { return nil, err } for _, zone := range zones { if p.domainFilter.Match(zone.Domain) { filteredZones = append(filteredZones, zone.Domain) log.Debugf("GoDaddy: %s zone found", zone.Domain) } } log.Infof("GoDaddy: %d zones found", len(filteredZones)) return filteredZones, nil } func (p *GDProvider) zonesRecords(ctx context.Context, all bool) ([]string, []gdRecords, error) { var allRecords []gdRecords zones, err := p.zones() if err != nil { return nil, nil, err } switch len(zones) { case 0: allRecords = []gdRecords{} case 1: record, err := p.records(&ctx, zones[0], all) if err != nil { return nil, nil, err } allRecords = append(allRecords, *record) default: chRecords := make(chan gdRecords, len(zones)) eg, ctx := errgroup.WithContext(ctx) for _, zoneName := range zones { zone := zoneName eg.Go(func() error { record, err := p.records(&ctx, zone, all) if err != nil { return err } chRecords <- *record return nil }) } if err := eg.Wait(); err != nil { return nil, nil, err } close(chRecords) for records := range chRecords { allRecords = append(allRecords, records) } } return zones, allRecords, nil } func (p *GDProvider) records(_ *context.Context, zone string, all bool) (*gdRecords, error) { var recordsIds []gdRecordField log.Debugf("GoDaddy: Getting records for %s", zone) if err := p.client.Get(fmt.Sprintf("/v1/domains/%s/records", zone), &recordsIds); err != nil { return nil, err } if all { return &gdRecords{ zone: zone, records: recordsIds, }, nil } results := &gdRecords{ zone: zone, records: make([]gdRecordField, 0, len(recordsIds)), } for _, rec := range recordsIds { if provider.SupportedRecordType(rec.Type) { log.Debugf("GoDaddy: Record %s for %s is %+v", rec.Name, zone, rec) results.records = append(results.records, rec) } else { log.Infof("GoDaddy: Ignore record %s for %s is %+v", rec.Name, zone, rec) } } return results, nil } func (p *GDProvider) groupByNameAndType(zoneRecords []gdRecords) []*endpoint.Endpoint { endpoints := []*endpoint.Endpoint{} // group supported records by name and type groupsByZone := map[string]map[string][]gdRecordField{} for _, zone := range zoneRecords { groups := map[string][]gdRecordField{} groupsByZone[zone.zone] = groups for _, r := range zone.records { groupBy := fmt.Sprintf("%s - %s", r.Type, r.Name) if _, ok := groups[groupBy]; !ok { groups[groupBy] = []gdRecordField{} } groups[groupBy] = append(groups[groupBy], r) } } // create single endpoint with all the targets for each name/type for zoneName, groups := range groupsByZone { for _, records := range groups { targets := []string{} for _, record := range records { targets = append(targets, record.Data) } var recordName string if records[0].Name == "@" { recordName = strings.TrimPrefix(zoneName, ".") } else { recordName = strings.TrimPrefix(fmt.Sprintf("%s.%s", records[0].Name, zoneName), ".") } ep := endpoint.NewEndpointWithTTL( recordName, records[0].Type, endpoint.TTL(records[0].TTL), targets..., ) endpoints = append(endpoints, ep) } } return endpoints } // Records returns the list of records in all relevant zones. func (p *GDProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { _, records, err := p.zonesRecords(ctx, false) if err != nil { return nil, err } endpoints := p.groupByNameAndType(records) log.Infof("GoDaddy: %d endpoints have been found", len(endpoints)) return endpoints, nil } func (p *GDProvider) appendChange(action int, endpoints []*endpoint.Endpoint, allChanges []gdEndpoint) []gdEndpoint { for _, e := range endpoints { allChanges = append(allChanges, gdEndpoint{ action: action, endpoint: e, }) } return allChanges } func (p *GDProvider) changeAllRecords(endpoints []gdEndpoint, zoneRecords []*gdRecords) error { zoneNameIDMapper := gdZoneIDName{} for _, zoneRecord := range zoneRecords { zoneNameIDMapper.add(zoneRecord.zone, zoneRecord) } for _, e := range endpoints { dnsName := e.endpoint.DNSName zone, zoneRecord := zoneNameIDMapper.findZoneRecord(dnsName) if zone == "" { log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", dnsName) } else { dnsName = strings.TrimSuffix(dnsName, "."+zone) if dnsName == zone { dnsName = "" } if e.endpoint.RecordType == endpoint.RecordTypeA && (len(dnsName) == 0) { dnsName = "@" } e.endpoint.RecordTTL = endpoint.TTL(maxOf(defaultTTL, int64(e.endpoint.RecordTTL))) if err := zoneRecord.applyEndpoint(e.action, p.client, *e.endpoint, dnsName, p.DryRun); err != nil { log.Errorf("Unable to apply change %s on record %s type %s, %v", actionNames[e.action], dnsName, e.endpoint.RecordType, err) return err } } } return nil } // ApplyChanges applies a given set of changes in a given zone. func (p *GDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { if countTargets(changes) == 0 { return nil } _, records, err := p.zonesRecords(ctx, true) if err != nil { return err } changedZoneRecords := make([]*gdRecords, len(records)) for i := range records { changedZoneRecords[i] = &records[i] } var allChanges []gdEndpoint allChanges = p.appendChange(gdDelete, changes.Delete, allChanges) iOldSkip := make(map[int]bool) iNewSkip := make(map[int]bool) for iOld, recOld := range changes.UpdateOld { for iNew, recNew := range changes.UpdateNew { if recOld.DNSName == recNew.DNSName && recOld.RecordType == recNew.RecordType { ReplaceEndpoints := []*endpoint.Endpoint{recNew} allChanges = p.appendChange(gdReplace, ReplaceEndpoints, allChanges) iOldSkip[iOld] = true iNewSkip[iNew] = true break } } } for iOld, recOld := range changes.UpdateOld { _, found := iOldSkip[iOld] if found { continue } for iNew, recNew := range changes.UpdateNew { _, found := iNewSkip[iNew] if found { continue } if recOld.DNSName != recNew.DNSName { continue } DeleteEndpoints := []*endpoint.Endpoint{recOld} CreateEndpoints := []*endpoint.Endpoint{recNew} allChanges = p.appendChange(gdDelete, DeleteEndpoints, allChanges) allChanges = p.appendChange(gdCreate, CreateEndpoints, allChanges) break } } allChanges = p.appendChange(gdCreate, changes.Create, allChanges) log.Infof("GoDaddy: %d changes will be done", len(allChanges)) if err = p.changeAllRecords(allChanges, changedZoneRecords); err != nil { return err } return nil } func (p *gdRecords) addRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error { var response GDErrorResponse for _, target := range endpoint.Targets { change := gdRecordField{ Type: endpoint.RecordType, Name: dnsName, TTL: int64(endpoint.RecordTTL), Data: target, } p.records = append(p.records, change) p.changed = true log.Debugf("GoDaddy: Add an entry %s to zone %s", change.String(), p.zone) if dryRun { log.Infof("[DryRun] - Add record %s.%s of type %s %s", change.Name, p.zone, change.Type, toString(change)) } else if err := client.Patch(fmt.Sprintf("/v1/domains/%s/records", p.zone), []gdRecordField{change}, &response); err != nil { log.Errorf("Add record %s.%s of type %s failed: %s", change.Name, p.zone, change.Type, response) return err } } return nil } func (p *gdRecords) replaceRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error { changed := []gdReplaceRecordField{} records := []string{} for _, target := range endpoint.Targets { change := gdRecordField{ Type: endpoint.RecordType, Name: dnsName, TTL: int64(endpoint.RecordTTL), Data: target, } for index, record := range p.records { if record.Type == change.Type && record.Name == change.Name { p.records[index] = change p.changed = true } } records = append(records, target) changed = append(changed, gdReplaceRecordField{ Data: change.Data, TTL: change.TTL, Port: change.Port, Priority: change.Priority, Weight: change.Weight, Protocol: change.Protocol, Service: change.Service, }) } var response GDErrorResponse if dryRun { log.Infof("[DryRun] - Replace record %s.%s of type %s %s", dnsName, p.zone, endpoint.RecordType, records) return nil } log.Debugf("Replace record %s.%s of type %s %s", dnsName, p.zone, endpoint.RecordType, records) if err := client.Put(fmt.Sprintf("/v1/domains/%s/records/%s/%s", p.zone, endpoint.RecordType, dnsName), changed, &response); err != nil { log.Errorf("Replace record %s.%s of type %s failed: %v", dnsName, p.zone, endpoint.RecordType, response) return err } return nil } // Remove one record from the record list func (p *gdRecords) deleteRecord(client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error { records := []string{} for _, target := range endpoint.Targets { change := gdRecordField{ Type: endpoint.RecordType, Name: dnsName, TTL: int64(endpoint.RecordTTL), Data: target, } records = append(records, target) log.Debugf("GoDaddy: Delete an entry %s from zone %s", change.String(), p.zone) deleteIndex := -1 for index, record := range p.records { if record.Type == change.Type && record.Name == change.Name && record.Data == change.Data { deleteIndex = index break } } if deleteIndex >= 0 { p.records[deleteIndex] = p.records[len(p.records)-1] p.records = p.records[:len(p.records)-1] p.changed = true } } if dryRun { log.Infof("[DryRun] - Delete record %s.%s of type %s %s", dnsName, p.zone, endpoint.RecordType, records) return nil } var response GDErrorResponse if err := client.Delete(fmt.Sprintf("/v1/domains/%s/records/%s/%s", p.zone, endpoint.RecordType, dnsName), &response); err != nil { log.Errorf("Delete record %s.%s of type %s failed: %v", dnsName, p.zone, endpoint.RecordType, response) return err } return nil } func (p *gdRecords) applyEndpoint(action int, client gdClient, endpoint endpoint.Endpoint, dnsName string, dryRun bool) error { switch action { case gdCreate: return p.addRecord(client, endpoint, dnsName, dryRun) case gdReplace: return p.replaceRecord(client, endpoint, dnsName, dryRun) case gdDelete: return p.deleteRecord(client, endpoint, dnsName, dryRun) } return nil } func (c gdRecordField) String() string { return fmt.Sprintf("%s %d IN %s %s", c.Name, c.TTL, c.Type, c.Data) } func countTargets(p *plan.Changes) int { changes := [][]*endpoint.Endpoint{p.Create, p.UpdateNew, p.UpdateOld, p.Delete} count := 0 for _, endpoints := range changes { for _, ep := range endpoints { count += len(ep.Targets) } } return count } func maxOf(vars ...int64) int64 { return slices.Max(vars) } func toString(obj any) string { b, err := json.MarshalIndent(obj, "", " ") if err != nil { return fmt.Sprintf("<%v>", err) } return string(b) } ================================================ FILE: provider/godaddy/godaddy_test.go ================================================ /* Copyright 2017 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 godaddy import ( "encoding/json" "errors" "sort" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) type mockGoDaddyClient struct { mock.Mock currentTest *testing.T } func newMockGoDaddyClient(t *testing.T) *mockGoDaddyClient { return &mockGoDaddyClient{ currentTest: t, } } var ( zoneNameExampleOrg string = "example.org" zoneNameExampleNet string = "example.net" ) func (c *mockGoDaddyClient) Post(endpoint string, input any, output any) error { log.Infof("POST: %s - %v", endpoint, input) stub := c.Called(endpoint, input) data, err := json.Marshal(stub.Get(0)) require.NoError(c.currentTest, err) err = json.Unmarshal(data, output) require.NoError(c.currentTest, err) return stub.Error(1) } func (c *mockGoDaddyClient) Patch(endpoint string, input any, output any) error { log.Infof("PATCH: %s - %v", endpoint, input) stub := c.Called(endpoint, input) data, err := json.Marshal(stub.Get(0)) require.NoError(c.currentTest, err) err = json.Unmarshal(data, output) require.NoError(c.currentTest, err) return stub.Error(1) } func (c *mockGoDaddyClient) Put(endpoint string, input any, output any) error { log.Infof("PUT: %s - %v", endpoint, input) stub := c.Called(endpoint, input) data, err := json.Marshal(stub.Get(0)) require.NoError(c.currentTest, err) err = json.Unmarshal(data, output) require.NoError(c.currentTest, err) return stub.Error(1) } func (c *mockGoDaddyClient) Get(endpoint string, output any) error { log.Infof("GET: %s", endpoint) stub := c.Called(endpoint) data, err := json.Marshal(stub.Get(0)) require.NoError(c.currentTest, err) err = json.Unmarshal(data, output) require.NoError(c.currentTest, err) return stub.Error(1) } func (c *mockGoDaddyClient) Delete(endpoint string, output any) error { log.Infof("DELETE: %s", endpoint) stub := c.Called(endpoint) data, err := json.Marshal(stub.Get(0)) require.NoError(c.currentTest, err) err = json.Unmarshal(data, output) require.NoError(c.currentTest, err) return stub.Error(1) } func TestGoDaddyZones(t *testing.T) { assert := assert.New(t) client := newMockGoDaddyClient(t) provider := &GDProvider{ client: client, domainFilter: endpoint.NewDomainFilter([]string{"com"}), } // Basic zones client.On("Get", domainsURI).Return([]gdZone{ { Domain: "example.com", }, { Domain: "example.net", }, }, nil).Once() domains, err := provider.zones() assert.NoError(err) assert.Contains(domains, "example.com") assert.NotContains(domains, "example.net") client.AssertExpectations(t) // Error on getting zones client.On("Get", domainsURI).Return(nil, ErrAPIDown).Once() domains, err = provider.zones() assert.Error(err) assert.Nil(domains) client.AssertExpectations(t) } func TestGoDaddyZoneRecords(t *testing.T) { assert := assert.New(t) client := newMockGoDaddyClient(t) provider := &GDProvider{ client: client, } // Basic zones records client.On("Get", domainsURI).Return([]gdZone{ { Domain: zoneNameExampleNet, }, }, nil).Once() client.On("Get", "/v1/domains/example.net/records").Return([]gdRecordField{ { Name: "godaddy", Type: "NS", TTL: defaultTTL, Data: "203.0.113.42", }, { Name: "godaddy", Type: "A", TTL: defaultTTL, Data: "203.0.113.42", }, }, nil).Once() zones, records, err := provider.zonesRecords(t.Context(), true) assert.NoError(err) assert.ElementsMatch(zones, []string{ zoneNameExampleNet, }) assert.ElementsMatch(records, []gdRecords{ { zone: zoneNameExampleNet, records: []gdRecordField{ { Name: "godaddy", Type: "NS", TTL: defaultTTL, Data: "203.0.113.42", }, { Name: "godaddy", Type: "A", TTL: defaultTTL, Data: "203.0.113.42", }, }, }, }) client.AssertExpectations(t) // Error on getting zones list client.On("Get", domainsURI).Return(nil, ErrAPIDown).Once() zones, records, err = provider.zonesRecords(t.Context(), false) assert.Error(err) assert.Nil(zones) assert.Nil(records) client.AssertExpectations(t) // Error on getting zone records client.On("Get", domainsURI).Return([]gdZone{ { Domain: zoneNameExampleNet, }, }, nil).Once() client.On("Get", "/v1/domains/example.net/records").Return(nil, ErrAPIDown).Once() zones, records, err = provider.zonesRecords(t.Context(), false) assert.Error(err) assert.Nil(zones) assert.Nil(records) client.AssertExpectations(t) // Error on getting zone record detail client.On("Get", domainsURI).Return([]gdZone{ { Domain: zoneNameExampleNet, }, }, nil).Once() client.On("Get", "/v1/domains/example.net/records").Return(nil, ErrAPIDown).Once() zones, records, err = provider.zonesRecords(t.Context(), false) assert.Error(err) assert.Nil(zones) assert.Nil(records) client.AssertExpectations(t) } func TestGoDaddyRecords(t *testing.T) { assert := assert.New(t) client := newMockGoDaddyClient(t) provider := &GDProvider{ client: client, } // Basic zones records client.On("Get", domainsURI).Return([]gdZone{ { Domain: zoneNameExampleOrg, }, { Domain: zoneNameExampleNet, }, }, nil).Once() client.On("Get", "/v1/domains/example.org/records").Return([]gdRecordField{ { Name: "@", Type: "A", TTL: defaultTTL, Data: "203.0.113.42", }, { Name: "www", Type: "CNAME", TTL: defaultTTL, Data: "example.org", }, }, nil).Once() client.On("Get", "/v1/domains/example.net/records").Return([]gdRecordField{ { Name: "godaddy", Type: "A", TTL: defaultTTL, Data: "203.0.113.42", }, { Name: "godaddy", Type: "A", TTL: defaultTTL, Data: "203.0.113.43", }, }, nil).Once() endpoints, err := provider.Records(t.Context()) assert.NoError(err) // Little fix for multi targets endpoint for _, endpoint := range endpoints { sort.Strings(endpoint.Targets) } assert.ElementsMatch(endpoints, []*endpoint.Endpoint{ { DNSName: "godaddy.example.net", RecordType: "A", RecordTTL: defaultTTL, Labels: endpoint.NewLabels(), Targets: []string{ "203.0.113.42", "203.0.113.43", }, }, { DNSName: "example.org", RecordType: "A", RecordTTL: defaultTTL, Labels: endpoint.NewLabels(), Targets: []string{ "203.0.113.42", }, }, { DNSName: "www.example.org", RecordType: "CNAME", RecordTTL: defaultTTL, Labels: endpoint.NewLabels(), Targets: []string{ "example.org", }, }, }) client.AssertExpectations(t) // Error getting zone client.On("Get", domainsURI).Return(nil, ErrAPIDown).Once() endpoints, err = provider.Records(t.Context()) assert.Error(err) assert.Nil(endpoints) client.AssertExpectations(t) } func TestGoDaddyChange(t *testing.T) { assert := assert.New(t) client := newMockGoDaddyClient(t) provider := &GDProvider{ client: client, } changes := plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: ".example.net", RecordType: "A", RecordTTL: defaultTTL, Targets: []string{ "203.0.113.42", }, }, }, Delete: []*endpoint.Endpoint{ { DNSName: "godaddy.example.net", RecordType: "A", Targets: []string{ "203.0.113.43", }, }, }, } // Fetch domains client.On("Get", domainsURI).Return([]gdZone{ { Domain: zoneNameExampleNet, }, }, nil).Once() // Fetch record client.On("Get", "/v1/domains/example.net/records").Return([]gdRecordField{ { Name: "godaddy", Type: "A", TTL: defaultTTL, Data: "203.0.113.43", }, }, nil).Once() // Add entry client.On("Patch", "/v1/domains/example.net/records", []gdRecordField{ { Name: "@", Type: "A", TTL: defaultTTL, Data: "203.0.113.42", }, }).Return(nil, nil).Once() // Delete entry client.On("Delete", "/v1/domains/example.net/records/A/godaddy").Return(nil, nil).Once() assert.NoError(provider.ApplyChanges(t.Context(), &changes)) client.AssertExpectations(t) } const ( operationFailedTestErrCode = "GD500" operationFailedTestReason = "Could not apply request" recordNotFoundErrCode = "GD404" recordNotFoundReason = "The requested record is not found in DNS zone" ) func TestGoDaddyErrorResponse(t *testing.T) { assert := assert.New(t) client := newMockGoDaddyClient(t) provider := &GDProvider{ client: client, } changes := plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: ".example.net", RecordType: "A", RecordTTL: defaultTTL, Targets: []string{ "203.0.113.42", }, }, }, Delete: []*endpoint.Endpoint{ { DNSName: "godaddy.example.net", RecordType: "A", Targets: []string{ "203.0.113.43", }, }, }, } // Fetch domains client.On("Get", domainsURI).Return([]gdZone{ { Domain: zoneNameExampleNet, }, }, nil).Once() // Fetch record client.On("Get", "/v1/domains/example.net/records").Return([]gdRecordField{ { Name: "godaddy", Type: "A", TTL: defaultTTL, Data: "203.0.113.43", }, }, nil).Once() // Delete entry client.On("Delete", "/v1/domains/example.net/records/A/godaddy").Return(GDErrorResponse{ Code: operationFailedTestErrCode, Message: operationFailedTestReason, Fields: []GDErrorField{{ Code: recordNotFoundErrCode, Message: recordNotFoundReason, }}, }, errors.New(operationFailedTestReason)).Once() assert.Error(provider.ApplyChanges(t.Context(), &changes)) client.AssertExpectations(t) } ================================================ FILE: provider/google/google.go ================================================ /* Copyright 2017 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 google import ( "context" "fmt" "sort" "time" "cloud.google.com/go/compute/metadata" log "github.com/sirupsen/logrus" "golang.org/x/oauth2/google" dns "google.golang.org/api/dns/v1" googleapi "google.golang.org/api/googleapi" "google.golang.org/api/option" extdnshttp "sigs.k8s.io/external-dns/pkg/http" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( defaultTTL = 300 ) type managedZonesCreateCallInterface interface { Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error) } type managedZonesListCallInterface interface { Pages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error } type managedZonesServiceInterface interface { Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface List(project string) managedZonesListCallInterface } type resourceRecordSetsListCallInterface interface { Pages(ctx context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error } type resourceRecordSetsClientInterface interface { List(project string, managedZone string) resourceRecordSetsListCallInterface } type changesCreateCallInterface interface { Do(opts ...googleapi.CallOption) (*dns.Change, error) } type changesServiceInterface interface { Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface } type resourceRecordSetsService struct { service *dns.ResourceRecordSetsService } func (r resourceRecordSetsService) List(project string, managedZone string) resourceRecordSetsListCallInterface { return r.service.List(project, managedZone) } type managedZonesService struct { service *dns.ManagedZonesService } func (m managedZonesService) Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface { return m.service.Create(project, managedzone) } func (m managedZonesService) List(project string) managedZonesListCallInterface { return m.service.List(project) } type changesService struct { service *dns.ChangesService } func (c changesService) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface { return c.service.Create(project, managedZone, change) } // GoogleProvider is an implementation of Provider for Google CloudDNS. type GoogleProvider struct { provider.BaseProvider // The Google project to work in project string // Enabled dry-run will print any modifying actions rather than execute them. dryRun bool // Max batch size to submit to Google Cloud DNS per transaction. batchChangeSize int // Interval between batch updates. batchChangeInterval time.Duration // only consider hosted zones managing domains ending in this suffix domainFilter *endpoint.DomainFilter // filter for zones based on visibility zoneTypeFilter provider.ZoneTypeFilter // only consider hosted zones ending with this zone id zoneIDFilter provider.ZoneIDFilter // A client for managing resource record sets resourceRecordSetsClient resourceRecordSetsClientInterface // A client for managing hosted zones managedZonesClient managedZonesServiceInterface // A client for managing change sets changesClient changesServiceInterface // The context parameter to be passed for gcloud API calls. ctx context.Context } // New creates a Google Cloud DNS provider from the given configuration. func New(ctx context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(ctx, cfg.GoogleProject, domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.GoogleBatchChangeSize, cfg.GoogleBatchChangeInterval, cfg.GoogleZoneVisibility, cfg.DryRun) } // newProvider initializes a new Google CloudDNS based Provider. func newProvider(ctx context.Context, project string, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, batchChangeSize int, batchChangeInterval time.Duration, zoneVisibility string, dryRun bool) (*GoogleProvider, error) { gcloud, err := google.DefaultClient(ctx, dns.NdevClouddnsReadwriteScope) if err != nil { return nil, err } gcloud = extdnshttp.NewInstrumentedClient(gcloud) dnsClient, err := dns.NewService(ctx, option.WithHTTPClient(gcloud)) if err != nil { return nil, err } if project == "" { mProject, mErr := metadata.ProjectIDWithContext(ctx) if mErr != nil { return nil, fmt.Errorf("failed to auto-detect the project id: %w", mErr) } log.Infof("Google project auto-detected: %s", mProject) project = mProject } zoneTypeFilter := provider.NewZoneTypeFilter(zoneVisibility) return &GoogleProvider{ project: project, dryRun: dryRun, batchChangeSize: batchChangeSize, batchChangeInterval: batchChangeInterval, domainFilter: domainFilter, zoneTypeFilter: zoneTypeFilter, zoneIDFilter: zoneIDFilter, resourceRecordSetsClient: resourceRecordSetsService{dnsClient.ResourceRecordSets}, managedZonesClient: managedZonesService{dnsClient.ManagedZones}, changesClient: changesService{dnsClient.Changes}, ctx: ctx, }, nil } // Zones returns the list of hosted zones. func (p *GoogleProvider) Zones(ctx context.Context) (map[string]*dns.ManagedZone, error) { zones := make(map[string]*dns.ManagedZone) f := func(resp *dns.ManagedZonesListResponse) error { for _, zone := range resp.ManagedZones { if zone.PeeringConfig == nil { if p.domainFilter.Match(zone.DnsName) && p.zoneTypeFilter.Match(zone.Visibility) && (p.zoneIDFilter.Match(fmt.Sprintf("%v", zone.Id)) || p.zoneIDFilter.Match(fmt.Sprintf("%v", zone.Name))) { zones[zone.Name] = zone log.Debugf("Matched %s (zone: %s) (visibility: %s)", zone.DnsName, zone.Name, zone.Visibility) } else { log.Debugf("Filtered %s (zone: %s) (visibility: %s)", zone.DnsName, zone.Name, zone.Visibility) } } else { log.Debugf("Filtered peering zone %s (zone: %s) (visibility: %s)", zone.DnsName, zone.Name, zone.Visibility) } } return nil } log.Debugf("Matching zones against domain filters: %v", p.domainFilter) if err := p.managedZonesClient.List(p.project).Pages(ctx, f); err != nil { return nil, provider.NewSoftErrorf("failed to list zones: %w", err) } if len(zones) == 0 { log.Warnf("No zones in the project, %s, match domain filters: %v", p.project, p.domainFilter) } for _, zone := range zones { log.Debugf("Considering zone: %s (domain: %s)", zone.Name, zone.DnsName) } return zones, nil } // Records returns the list of records in all relevant zones. func (p *GoogleProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.Zones(ctx) if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, 0) f := func(resp *dns.ResourceRecordSetsListResponse) error { for _, r := range resp.Rrsets { if !p.SupportedRecordType(r.Type) { continue } endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.Ttl), r.Rrdatas...)) } return nil } for _, z := range zones { if err := p.resourceRecordSetsClient.List(p.project, z.Name).Pages(ctx, f); err != nil { return nil, provider.NewSoftErrorf("failed to list records in zone %s: %v", z.Name, err) } } return endpoints, nil } // ApplyChanges applies a given set of changes in a given zone. func (p *GoogleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { change := &dns.Change{} change.Additions = append(change.Additions, p.newFilteredRecords(changes.Create)...) change.Additions = append(change.Additions, p.newFilteredRecords(changes.UpdateNew)...) change.Deletions = append(change.Deletions, p.newFilteredRecords(changes.UpdateOld)...) change.Deletions = append(change.Deletions, p.newFilteredRecords(changes.Delete)...) return p.submitChange(ctx, change) } // SupportedRecordType returns true if the record type is supported by the provider func (p *GoogleProvider) SupportedRecordType(recordType string) bool { switch recordType { case "MX": return true default: return provider.SupportedRecordType(recordType) } } // newFilteredRecords returns a collection of RecordSets based on the given endpoints and domainFilter. func (p *GoogleProvider) newFilteredRecords(endpoints []*endpoint.Endpoint) []*dns.ResourceRecordSet { var records []*dns.ResourceRecordSet for _, ep := range endpoints { if p.domainFilter.Match(ep.DNSName) { records = append(records, newRecord(ep)) } } return records } // submitChange takes a zone and a Change and sends it to Google. func (p *GoogleProvider) submitChange(ctx context.Context, change *dns.Change) error { if len(change.Additions) == 0 && len(change.Deletions) == 0 { log.Info("All records are already up to date") return nil } zones, err := p.Zones(ctx) if err != nil { return err } // separate into per-zone change sets to be passed to the API. changes := separateChange(zones, change) for zone, change := range changes { for batch, c := range batchChange(change, p.batchChangeSize) { log.Infof("Change zone: %v batch #%d", zone, batch) for _, del := range c.Deletions { log.Infof("Del records: %s %s %s %d", del.Name, del.Type, del.Rrdatas, del.Ttl) } for _, add := range c.Additions { log.Infof("Add records: %s %s %s %d", add.Name, add.Type, add.Rrdatas, add.Ttl) } if p.dryRun { continue } if _, err := p.changesClient.Create(p.project, zone, c).Do(); err != nil { return provider.NewSoftErrorf("failed to create changes: %w", err) } time.Sleep(p.batchChangeInterval) } } return nil } // batchChange separates a zone in multiple transaction. func batchChange(change *dns.Change, batchSize int) []*dns.Change { var changes []*dns.Change if batchSize == 0 { return append(changes, change) } type dnsChange struct { additions []*dns.ResourceRecordSet deletions []*dns.ResourceRecordSet } changesByName := map[string]*dnsChange{} for _, a := range change.Additions { change, ok := changesByName[a.Name] if !ok { change = &dnsChange{} changesByName[a.Name] = change } change.additions = append(change.additions, a) } for _, a := range change.Deletions { change, ok := changesByName[a.Name] if !ok { change = &dnsChange{} changesByName[a.Name] = change } change.deletions = append(change.deletions, a) } names := make([]string, 0) for v := range changesByName { names = append(names, v) } sort.Strings(names) currentChange := &dns.Change{} var totalChanges int for _, name := range names { c := changesByName[name] totalChangesByName := len(c.additions) + len(c.deletions) if totalChangesByName > batchSize { log.Warnf("Total changes for %s exceeds max batch size of %d, total changes: %d", name, batchSize, totalChangesByName) continue } if totalChanges+totalChangesByName > batchSize { totalChanges = 0 changes = append(changes, currentChange) currentChange = &dns.Change{} } currentChange.Additions = append(currentChange.Additions, c.additions...) currentChange.Deletions = append(currentChange.Deletions, c.deletions...) totalChanges += totalChangesByName } if totalChanges > 0 { changes = append(changes, currentChange) } return changes } // separateChange separates a multi-zone change into a single change per zone. func separateChange(zones map[string]*dns.ManagedZone, change *dns.Change) map[string]*dns.Change { changes := make(map[string]*dns.Change) zoneNameIDMapper := provider.ZoneIDName{} for _, z := range zones { zoneNameIDMapper[z.Name] = z.DnsName changes[z.Name] = &dns.Change{ Additions: []*dns.ResourceRecordSet{}, Deletions: []*dns.ResourceRecordSet{}, } } for _, a := range change.Additions { if zoneName, _ := zoneNameIDMapper.FindZone(provider.EnsureTrailingDot(a.Name)); zoneName != "" { changes[zoneName].Additions = append(changes[zoneName].Additions, a) } else { log.Warnf("No matching zone for record addition: %s %s %s %d", a.Name, a.Type, a.Rrdatas, a.Ttl) } } for _, d := range change.Deletions { if zoneName, _ := zoneNameIDMapper.FindZone(provider.EnsureTrailingDot(d.Name)); zoneName != "" { changes[zoneName].Deletions = append(changes[zoneName].Deletions, d) } else { log.Warnf("No matching zone for record deletion: %s %s %s %d", d.Name, d.Type, d.Rrdatas, d.Ttl) } } // separating a change could lead to empty sub changes, remove them here. for zone, change := range changes { if len(change.Additions) == 0 && len(change.Deletions) == 0 { delete(changes, zone) } } return changes } // newRecord returns a RecordSet based on the given endpoint. func newRecord(ep *endpoint.Endpoint) *dns.ResourceRecordSet { // TODO(linki): works around appending a trailing dot to TXT records. I think // we should go back to storing DNS names with a trailing dot internally. This // way we can use it has is here and trim it off if it exists when necessary. targets := make([]string, len(ep.Targets)) copy(targets, []string(ep.Targets)) if ep.RecordType == endpoint.RecordTypeCNAME { targets[0] = provider.EnsureTrailingDot(targets[0]) } if ep.RecordType == endpoint.RecordTypeMX { for i, mxRecord := range ep.Targets { targets[i] = provider.EnsureTrailingDot(mxRecord) } } if ep.RecordType == endpoint.RecordTypeSRV { for i, srvRecord := range ep.Targets { targets[i] = provider.EnsureTrailingDot(srvRecord) } } if ep.RecordType == endpoint.RecordTypeNS { for i, nsRecord := range ep.Targets { targets[i] = provider.EnsureTrailingDot(nsRecord) } } // no annotation results in a Ttl of 0, default to 300 for backwards-compatibility var ttl int64 = defaultTTL if ep.RecordTTL.IsConfigured() { ttl = int64(ep.RecordTTL) } return &dns.ResourceRecordSet{ Name: provider.EnsureTrailingDot(ep.DNSName), Rrdatas: targets, Ttl: ttl, Type: ep.RecordType, } } ================================================ FILE: provider/google/google_test.go ================================================ /* Copyright 2017 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 google import ( "errors" "fmt" "net/http" "slices" "sort" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/net/context" dns "google.golang.org/api/dns/v1" "google.golang.org/api/googleapi" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) var ( testZones = map[string]*dns.ManagedZone{} testRecords = map[string]map[string]*dns.ResourceRecordSet{} googleDefaultBatchChangeSize = 4000 ) type mockManagedZonesCreateCall struct { project string managedZone *dns.ManagedZone } func (m *mockManagedZonesCreateCall) Do(_ ...googleapi.CallOption) (*dns.ManagedZone, error) { zoneKey := zoneKey(m.project, m.managedZone.Name) if _, ok := testZones[zoneKey]; ok { return nil, &googleapi.Error{Code: http.StatusConflict} } testZones[zoneKey] = m.managedZone return m.managedZone, nil } type mockManagedZonesListCall struct { project string zonesListSoftErr error } func (m *mockManagedZonesListCall) Pages(_ context.Context, f func(*dns.ManagedZonesListResponse) error) error { zones := []*dns.ManagedZone{} for k, v := range testZones { if strings.HasPrefix(k, m.project+"/") { zones = append(zones, v) } } if m.zonesListSoftErr != nil { return m.zonesListSoftErr } return f(&dns.ManagedZonesListResponse{ManagedZones: zones}) } type mockManagedZonesClient struct { zonesErr error } func (m *mockManagedZonesClient) Create(project string, managedZone *dns.ManagedZone) managedZonesCreateCallInterface { return &mockManagedZonesCreateCall{project: project, managedZone: managedZone} } func (m *mockManagedZonesClient) List(project string) managedZonesListCallInterface { return &mockManagedZonesListCall{project: project, zonesListSoftErr: m.zonesErr} } type mockResourceRecordSetsListCall struct { project string managedZone string recordsListSoftErr error } func (m *mockResourceRecordSetsListCall) Pages(_ context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error { zoneKey := zoneKey(m.project, m.managedZone) if _, ok := testZones[zoneKey]; !ok { return &googleapi.Error{Code: http.StatusNotFound} } resp := []*dns.ResourceRecordSet{} for _, v := range testRecords[zoneKey] { resp = append(resp, v) } if m.recordsListSoftErr != nil { return m.recordsListSoftErr } return f(&dns.ResourceRecordSetsListResponse{Rrsets: resp}) } type mockResourceRecordSetsClient struct { recordsErr error } func (m *mockResourceRecordSetsClient) List(project string, managedZone string) resourceRecordSetsListCallInterface { return &mockResourceRecordSetsListCall{project: project, managedZone: managedZone, recordsListSoftErr: m.recordsErr} } type mockChangesCreateCall struct { project string managedZone string change *dns.Change } func (m *mockChangesCreateCall) Do(_ ...googleapi.CallOption) (*dns.Change, error) { zoneKey := zoneKey(m.project, m.managedZone) if _, ok := testZones[zoneKey]; !ok { return nil, &googleapi.Error{Code: http.StatusNotFound} } if _, ok := testRecords[zoneKey]; !ok { testRecords[zoneKey] = make(map[string]*dns.ResourceRecordSet) } for _, c := range append(m.change.Additions, m.change.Deletions...) { if !isValidRecordSet(c) { return nil, &googleapi.Error{ Code: http.StatusBadRequest, Message: fmt.Sprintf("invalid record: %v", c), } } } for _, del := range m.change.Deletions { recordKey := recordKey(del.Type, del.Name) delete(testRecords[zoneKey], recordKey) } for _, add := range m.change.Additions { recordKey := recordKey(add.Type, add.Name) testRecords[zoneKey][recordKey] = add } return m.change, nil } type mockChangesClient struct{} func (m *mockChangesClient) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface { return &mockChangesCreateCall{project: project, managedZone: managedZone, change: change} } func zoneKey(project, zoneName string) string { return project + "/" + zoneName } func recordKey(recordType, recordName string) string { return recordType + "/" + recordName } func isValidRecordSet(recordSet *dns.ResourceRecordSet) bool { if !hasTrailingDot(recordSet.Name) { return false } switch recordSet.Type { case endpoint.RecordTypeCNAME: for _, rrd := range recordSet.Rrdatas { if !hasTrailingDot(rrd) { return false } } case endpoint.RecordTypeA, endpoint.RecordTypeTXT: if slices.ContainsFunc(recordSet.Rrdatas, hasTrailingDot) { return false } default: panic("unhandled record type") } return true } func hasTrailingDot(target string) bool { return strings.HasSuffix(target, ".") } func TestGoogleZonesIDFilter(t *testing.T) { provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"10002"}), provider.NewZoneTypeFilter(""), []*endpoint.Endpoint{}) zones, err := provider.Zones(t.Context()) require.NoError(t, err) validateZones(t, zones, map[string]*dns.ManagedZone{ "internal-2": {Name: "internal-2", DnsName: "cluster.local.", Id: 10002, Visibility: "private"}, }) } func TestGoogleZonesNameFilter(t *testing.T) { provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"internal-2"}), provider.NewZoneTypeFilter(""), []*endpoint.Endpoint{}) zones, err := provider.Zones(t.Context()) require.NoError(t, err) validateZones(t, zones, map[string]*dns.ManagedZone{ "internal-2": {Name: "internal-2", DnsName: "cluster.local.", Id: 10002, Visibility: "private"}, }) } func TestGoogleZonesVisibilityFilterPublic(t *testing.T) { provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"split-horizon-1"}), provider.NewZoneTypeFilter("public"), []*endpoint.Endpoint{}) zones, err := provider.Zones(t.Context()) require.NoError(t, err) validateZones(t, zones, map[string]*dns.ManagedZone{ "split-horizon-1": {Name: "split-horizon-1", DnsName: "cluster.local.", Id: 10001, Visibility: "public"}, }) } func TestGoogleZonesVisibilityFilterPrivate(t *testing.T) { provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"cluster.local."}), provider.NewZoneIDFilter([]string{"split-horizon-1"}), provider.NewZoneTypeFilter("public"), []*endpoint.Endpoint{}) zones, err := provider.Zones(t.Context()) require.NoError(t, err) validateZones(t, zones, map[string]*dns.ManagedZone{ "split-horizon-1": {Name: "split-horizon-1", DnsName: "cluster.local.", Id: 10001, Visibility: "public"}, }) } func TestGoogleZonesVisibilityFilterPrivatePeering(t *testing.T) { provider := newGoogleProviderZoneOverlap(t, endpoint.NewDomainFilter([]string{"svc.local."}), provider.NewZoneIDFilter([]string{""}), provider.NewZoneTypeFilter("private"), []*endpoint.Endpoint{}) zones, err := provider.Zones(t.Context()) require.NoError(t, err) validateZones(t, zones, map[string]*dns.ManagedZone{ "svc-local": {Name: "svc-local", DnsName: "svc.local.", Id: 1005, Visibility: "private"}, }) } func TestGoogleRecords(t *testing.T) { originalEndpoints := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("list-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(1), "1.2.3.4"), endpoint.NewEndpointWithTTL("list-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(2), "8.8.8.8"), endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(3), "foo.elb.amazonaws.com"), } provider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, originalEndpoints, nil, nil) records, err := provider.Records(t.Context()) require.NoError(t, err) validateEndpoints(t, records, originalEndpoints) } func TestGoogleRecordsFilter(t *testing.T) { originalEndpoints := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.8.8"), endpoint.NewEndpointWithTTL("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.8.8"), endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.4.4"), endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.4.4"), endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, defaultTTL, "bar.elb.amazonaws.com"), endpoint.NewEndpointWithTTL("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, defaultTTL, "qux.elb.amazonaws.com"), } provider := newGoogleProvider( t, endpoint.NewDomainFilter([]string{ // our two valid zones "zone-1.ext-dns-test-2.gcp.zalan.do.", "zone-2.ext-dns-test-2.gcp.zalan.do.", // we filter for a zone that doesn't exist, should have no effect. "zone-0.ext-dns-test-2.gcp.zalan.do.", // there exists a third zone "zone-3" that we want to exclude from being managed. }), provider.NewZoneIDFilter([]string{""}), false, originalEndpoints, nil, nil, ) // these records should be filtered out since they don't match a hosted zone or domain filter. ignoredEndpoints := []*endpoint.Endpoint{ endpoint.NewEndpoint("filter-create-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("filter-update-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("filter-delete-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("filter-create-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("filter-delete-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), } require.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{ Create: ignoredEndpoints, })) records, err := provider.Records(t.Context()) require.NoError(t, err) // assert that due to filtering no changes were made. validateEndpoints(t, records, originalEndpoints) } func TestGoogleApplyChanges(t *testing.T) { provider := newGoogleProvider( t, endpoint.NewDomainFilter([]string{ // our two valid zones "zone-1.ext-dns-test-2.gcp.zalan.do.", "zone-2.ext-dns-test-2.gcp.zalan.do.", // we filter for a zone that doesn't exist, should have no effect. "zone-0.ext-dns-test-2.gcp.zalan.do.", // there exists a third zone "zone-3" that we want to exclude from being managed. }), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.8.8"), endpoint.NewEndpointWithTTL("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.8.8"), endpoint.NewEndpointWithTTL("update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(10), "8.8.4.4"), endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.4.4"), endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, defaultTTL, "bar.elb.amazonaws.com"), endpoint.NewEndpointWithTTL("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, defaultTTL, "qux.elb.amazonaws.com"), }, nil, nil, ) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpointWithTTL("create-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(15), "8.8.4.4"), endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), endpoint.NewEndpoint("filter-create-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("nomatch-create-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.1"), } currentRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), endpoint.NewEndpoint("filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), } updatedRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpointWithTTL("update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(25), "4.3.2.1"), endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), endpoint.NewEndpoint("filter-update-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "5.6.7.8"), endpoint.NewEndpoint("nomatch-update-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.7.6.5"), } deleteRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), endpoint.NewEndpoint("filter-delete-test.zone-3.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.2"), endpoint.NewEndpoint("nomatch-delete-test.zone-0.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.2.2.1"), } changes := &plan.Changes{ Create: createRecords, UpdateNew: updatedRecords, UpdateOld: currentRecords, Delete: deleteRecords, } require.NoError(t, provider.ApplyChanges(t.Context(), changes)) records, err := provider.Records(t.Context()) require.NoError(t, err) validateEndpoints(t, records, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.8.8"), endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "1.2.3.4"), endpoint.NewEndpointWithTTL("create-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(15), "8.8.4.4"), endpoint.NewEndpointWithTTL("update-test-ttl.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, endpoint.TTL(25), "4.3.2.1"), endpoint.NewEndpointWithTTL("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, defaultTTL, "foo.elb.amazonaws.com"), endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, defaultTTL, "baz.elb.amazonaws.com"), }) } func TestGoogleApplyChangesDryRun(t *testing.T) { originalEndpoints := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.8.8"), endpoint.NewEndpointWithTTL("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.8.8"), endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.4.4"), endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, defaultTTL, "8.8.4.4"), endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, defaultTTL, "bar.elb.amazonaws.com"), endpoint.NewEndpointWithTTL("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, defaultTTL, "qux.elb.amazonaws.com"), } provider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), true, originalEndpoints, nil, nil) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), } currentRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), } updatedRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "4.3.2.1"), endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), } deleteRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), } changes := &plan.Changes{ Create: createRecords, UpdateNew: updatedRecords, UpdateOld: currentRecords, Delete: deleteRecords, } ctx := t.Context() require.NoError(t, provider.ApplyChanges(ctx, changes)) records, err := provider.Records(ctx) require.NoError(t, err) validateEndpoints(t, records, originalEndpoints) } func TestGoogleApplyChangesEmpty(t *testing.T) { provider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}, nil, nil) assert.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{})) } func TestNewFilteredRecords(t *testing.T) { provider := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}, nil, nil) records := provider.newFilteredRecords([]*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, 1, "8.8.4.4"), endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, 120, "8.8.4.4"), endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, 4000, "bar.elb.amazonaws.com"), endpoint.NewEndpointWithTTL("update-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.", endpoint.RecordTypeNS, 120, "foo.elb.amazonaws.com"), // test fallback to Ttl:300 when Ttl==0 : endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, 0, "8.8.8.8"), endpoint.NewEndpointWithTTL("update-test-mx.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeMX, 6000, "10 mail.elb.amazonaws.com"), endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), endpoint.NewEndpoint("delete-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do", endpoint.RecordTypeNS, "foo.elb.amazonaws.com"), }) validateChangeRecords(t, records, []*dns.ResourceRecordSet{ {Name: "update-test.zone-2.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 1}, {Name: "delete-test.zone-2.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"8.8.4.4"}, Type: "A", Ttl: 120}, {Name: "update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"bar.elb.amazonaws.com."}, Type: "CNAME", Ttl: 4000}, {Name: "update-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"foo.elb.amazonaws.com."}, Type: "NS", Ttl: 120}, {Name: "update-test.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"8.8.8.8"}, Type: "A", Ttl: 300}, {Name: "update-test-mx.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"10 mail.elb.amazonaws.com."}, Type: "MX", Ttl: 6000}, {Name: "delete-test.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"8.8.8.8"}, Type: "A", Ttl: 300}, {Name: "delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"qux.elb.amazonaws.com."}, Type: "CNAME", Ttl: 300}, {Name: "delete-test-ns.zone-1.ext-dns-test-2.gcp.zalan.do.", Rrdatas: []string{"foo.elb.amazonaws.com."}, Type: "NS", Ttl: 300}, }) } func TestSeparateChanges(t *testing.T) { change := &dns.Change{ Additions: []*dns.ResourceRecordSet{ {Name: "qux.foo.example.org.", Ttl: 1}, {Name: "qux.bar.example.org.", Ttl: 2}, }, Deletions: []*dns.ResourceRecordSet{ {Name: "wambo.foo.example.org.", Ttl: 10}, {Name: "wambo.bar.example.org.", Ttl: 20}, }, } zones := map[string]*dns.ManagedZone{ "foo-example-org": { Name: "foo-example-org", DnsName: "foo.example.org.", }, "bar-example-org": { Name: "bar-example-org", DnsName: "bar.example.org.", }, "baz-example-org": { Name: "baz-example-org", DnsName: "baz.example.org.", }, } changes := separateChange(zones, change) require.Len(t, changes, 2) validateChange(t, changes["foo-example-org"], &dns.Change{ Additions: []*dns.ResourceRecordSet{ {Name: "qux.foo.example.org.", Ttl: 1}, }, Deletions: []*dns.ResourceRecordSet{ {Name: "wambo.foo.example.org.", Ttl: 10}, }, }) validateChange(t, changes["bar-example-org"], &dns.Change{ Additions: []*dns.ResourceRecordSet{ {Name: "qux.bar.example.org.", Ttl: 2}, }, Deletions: []*dns.ResourceRecordSet{ {Name: "wambo.bar.example.org.", Ttl: 20}, }, }) } func TestGoogleBatchChangeSet(t *testing.T) { cs := &dns.Change{} for i := 1; i <= googleDefaultBatchChangeSize; i += 2 { cs.Additions = append(cs.Additions, &dns.ResourceRecordSet{ Name: fmt.Sprintf("host-%d.example.org.", i), Ttl: 2, }) cs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{ Name: fmt.Sprintf("host-%d.example.org.", i), Ttl: 20, }) } batchCs := batchChange(cs, googleDefaultBatchChangeSize) require.Len(t, batchCs, 1) sortChangesByName(cs) validateChange(t, batchCs[0], cs) } func TestGoogleBatchChangeSetExceeding(t *testing.T) { cs := &dns.Change{} const testCount = 50 const testLimit = 11 const expectedBatchCount = 5 for i := 1; i <= testCount; i += 2 { cs.Additions = append(cs.Additions, &dns.ResourceRecordSet{ Name: fmt.Sprintf("host-%d.example.org.", i), Ttl: 2, }) cs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{ Name: fmt.Sprintf("host-%d.example.org.", i), Ttl: 20, }) } batchCs := batchChange(cs, testLimit) require.Len(t, batchCs, expectedBatchCount) dnsChange := &dns.Change{} for _, c := range batchCs { dnsChange.Additions = append(dnsChange.Additions, c.Additions...) dnsChange.Deletions = append(dnsChange.Deletions, c.Deletions...) } require.Len(t, dnsChange.Additions, len(cs.Additions)) require.Len(t, dnsChange.Deletions, len(cs.Deletions)) sortChangesByName(cs) sortChangesByName(dnsChange) validateChange(t, dnsChange, cs) } func TestGoogleBatchChangeSetExceedingNameChange(t *testing.T) { cs := &dns.Change{} const testLimit = 1 cs.Additions = append(cs.Additions, &dns.ResourceRecordSet{ Name: "host-1.example.org.", Ttl: 2, }) cs.Deletions = append(cs.Deletions, &dns.ResourceRecordSet{ Name: "host-1.example.org.", Ttl: 20, }) batchCs := batchChange(cs, testLimit) require.Empty(t, batchCs) } func TestSoftErrListZonesConflict(t *testing.T) { p := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{}), false, []*endpoint.Endpoint{}, provider.NewSoftErrorf("failed to list zones"), nil) zones, err := p.Zones(t.Context()) require.Error(t, err) require.ErrorIs(t, err, provider.SoftError) require.Empty(t, zones) } func TestSoftErrListRecordsConflict(t *testing.T) { p := newGoogleProvider(t, endpoint.NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), provider.NewZoneIDFilter([]string{}), false, []*endpoint.Endpoint{}, nil, provider.NewSoftErrorf("failed to list records in zone")) records, err := p.Records(t.Context()) require.Error(t, err) require.ErrorIs(t, err, provider.SoftError) require.Empty(t, records) } func sortChangesByName(cs *dns.Change) { sort.SliceStable(cs.Additions, func(i, j int) bool { return cs.Additions[i].Name < cs.Additions[j].Name }) sort.SliceStable(cs.Deletions, func(i, j int) bool { return cs.Deletions[i].Name < cs.Deletions[j].Name }) } func validateZones(t *testing.T, zones map[string]*dns.ManagedZone, expected map[string]*dns.ManagedZone) { require.Len(t, zones, len(expected)) for i, zone := range zones { validateZone(t, zone, expected[i]) } } func validateZone(t *testing.T, zone *dns.ManagedZone, expected *dns.ManagedZone) { assert.Equal(t, expected.Name, zone.Name) assert.Equal(t, expected.DnsName, zone.DnsName) assert.Equal(t, expected.Visibility, zone.Visibility) } func validateChange(t *testing.T, change *dns.Change, expected *dns.Change) { validateChangeRecords(t, change.Additions, expected.Additions) validateChangeRecords(t, change.Deletions, expected.Deletions) } func validateChangeRecords(t *testing.T, records []*dns.ResourceRecordSet, expected []*dns.ResourceRecordSet) { require.Len(t, records, len(expected)) for i := range records { validateChangeRecord(t, records[i], expected[i]) } } func validateChangeRecord(t *testing.T, record *dns.ResourceRecordSet, expected *dns.ResourceRecordSet) { assert.Equal(t, expected.Name, record.Name) assert.Equal(t, expected.Rrdatas, record.Rrdatas) assert.Equal(t, expected.Ttl, record.Ttl) assert.Equal(t, expected.Type, record.Type) } func newGoogleProviderZoneOverlap(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneTypeFilter provider.ZoneTypeFilter, _ []*endpoint.Endpoint) *GoogleProvider { provider := &GoogleProvider{ project: "zalando-external-dns-test", dryRun: false, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, zoneTypeFilter: zoneTypeFilter, resourceRecordSetsClient: &mockResourceRecordSetsClient{}, managedZonesClient: &mockManagedZonesClient{}, changesClient: &mockChangesClient{}, } createZone(t, provider, &dns.ManagedZone{ Name: "internal-1", DnsName: "cluster.local.", Id: 10001, Visibility: "private", }) createZone(t, provider, &dns.ManagedZone{ Name: "internal-2", DnsName: "cluster.local.", Id: 10002, Visibility: "private", }) createZone(t, provider, &dns.ManagedZone{ Name: "internal-3", DnsName: "cluster.local.", Id: 10003, Visibility: "private", }) createZone(t, provider, &dns.ManagedZone{ Name: "split-horizon-1", DnsName: "cluster.local.", Id: 10004, Visibility: "public", }) createZone(t, provider, &dns.ManagedZone{ Name: "split-horizon-1", DnsName: "cluster.local.", Id: 10004, Visibility: "private", }) createZone(t, provider, &dns.ManagedZone{ Name: "svc-local", DnsName: "svc.local.", Id: 10005, Visibility: "private", }) createZone(t, provider, &dns.ManagedZone{ Name: "svc-local-peer", DnsName: "svc.local.", Id: 10006, Visibility: "private", PeeringConfig: &dns.ManagedZonePeeringConfig{TargetNetwork: nil}, }) return provider } func newGoogleProvider(t *testing.T, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, records []*endpoint.Endpoint, zonesErr, recordsErr error) *GoogleProvider { provider := &GoogleProvider{ project: "zalando-external-dns-test", dryRun: false, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, resourceRecordSetsClient: &mockResourceRecordSetsClient{ recordsErr: recordsErr, }, managedZonesClient: &mockManagedZonesClient{ zonesErr: zonesErr, }, changesClient: &mockChangesClient{}, } createZone(t, provider, &dns.ManagedZone{ Name: "zone-1-ext-dns-test-2-gcp-zalan-do", DnsName: "zone-1.ext-dns-test-2.gcp.zalan.do.", }) createZone(t, provider, &dns.ManagedZone{ Name: "zone-2-ext-dns-test-2-gcp-zalan-do", DnsName: "zone-2.ext-dns-test-2.gcp.zalan.do.", }) createZone(t, provider, &dns.ManagedZone{ Name: "zone-3-ext-dns-test-2-gcp-zalan-do", DnsName: "zone-3.ext-dns-test-2.gcp.zalan.do.", }) // filtered out by domain filter createZone(t, provider, &dns.ManagedZone{ Name: "zone-4-ext-dns-test-3-gcp-zalan-do", DnsName: "zone-4.ext-dns-test-3.gcp.zalan.do.", }) setupGoogleRecords(t, provider, records) provider.dryRun = dryRun return provider } func createZone(t *testing.T, p *GoogleProvider, zone *dns.ManagedZone) { zone.Description = "Testing zone for kubernetes.io/external-dns" if _, err := p.managedZonesClient.Create("zalando-external-dns-test", zone).Do(); err != nil { var errs *googleapi.Error if !errors.As(err, &errs) || errs.Code != http.StatusConflict { require.NoError(t, err) } } } func setupGoogleRecords(t *testing.T, provider *GoogleProvider, endpoints []*endpoint.Endpoint) { clearGoogleRecords(t, provider, "zone-1-ext-dns-test-2-gcp-zalan-do") clearGoogleRecords(t, provider, "zone-2-ext-dns-test-2-gcp-zalan-do") clearGoogleRecords(t, provider, "zone-3-ext-dns-test-2-gcp-zalan-do") ctx := t.Context() records, _ := provider.Records(ctx) validateEndpoints(t, records, []*endpoint.Endpoint{}) require.NoError(t, provider.ApplyChanges(t.Context(), &plan.Changes{ Create: endpoints, })) records, _ = provider.Records(ctx) validateEndpoints(t, records, endpoints) } func clearGoogleRecords(t *testing.T, provider *GoogleProvider, zone string) { recordSets := []*dns.ResourceRecordSet{} provider.resourceRecordSetsClient.List(provider.project, zone).Pages(t.Context(), func(resp *dns.ResourceRecordSetsListResponse) error { for _, r := range resp.Rrsets { switch r.Type { case endpoint.RecordTypeA, endpoint.RecordTypeCNAME: recordSets = append(recordSets, r) } } return nil }) if len(recordSets) != 0 { _, err := provider.changesClient.Create(provider.project, zone, &dns.Change{ Deletions: recordSets, }).Do() require.NoError(t, err) } } func validateEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) { assert.True(t, testutils.SameEndpoints(endpoints, expected), "actual and expected endpoints don't match. %s:%s", endpoints, expected) } ================================================ FILE: provider/inmemory/inmemory.go ================================================ /* Copyright 2017 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 inmemory import ( "context" "errors" "maps" "strings" log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) var ( // ErrZoneAlreadyExists error returned when zone cannot be created when it already exists ErrZoneAlreadyExists = errors.New("specified zone already exists") // ErrZoneNotFound error returned when specified zone does not exists ErrZoneNotFound = errors.New("specified zone not found") // ErrRecordAlreadyExists when create request is sent but record already exists ErrRecordAlreadyExists = errors.New("record already exists") // ErrRecordNotFound when update/delete request is sent but record not found ErrRecordNotFound = errors.New("record not found") // ErrDuplicateRecordFound when record is repeated in create/update/delete ErrDuplicateRecordFound = errors.New("invalid batch request") ) // InMemoryProvider - dns provider only used for testing purposes // initialized as dns provider with no records type InMemoryProvider struct { provider.BaseProvider domain endpoint.DomainFilterInterface client *inMemoryClient filter *filter OnApplyChanges func(ctx context.Context, changes *plan.Changes) OnRecords func() } // New creates an InMemory provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(InMemoryInitZones(cfg.InMemoryZones), InMemoryWithDomain(domainFilter), InMemoryWithLogging()), nil } // InMemoryOption allows to extend in-memory provider // TODO: review this pattern, and consider inline with other providers type InMemoryOption func(*InMemoryProvider) // InMemoryWithLogging injects logging when ApplyChanges is called func InMemoryWithLogging() InMemoryOption { return func(p *InMemoryProvider) { p.OnApplyChanges = func(_ context.Context, changes *plan.Changes) { for _, v := range changes.Create { log.Infof("CREATE: %v", v) } for _, v := range changes.UpdateOld { log.Infof("UPDATE (old): %v", v) } for _, v := range changes.UpdateNew { log.Infof("UPDATE (new): %v", v) } for _, v := range changes.Delete { log.Infof("DELETE: %v", v) } } } } // InMemoryWithDomain modifies the domain on which dns zones are filtered func InMemoryWithDomain(domainFilter *endpoint.DomainFilter) InMemoryOption { return func(p *InMemoryProvider) { p.domain = domainFilter } } // InMemoryInitZones pre-seeds the InMemoryProvider with given zones func InMemoryInitZones(zones []string) InMemoryOption { return func(p *InMemoryProvider) { for _, z := range zones { if err := p.CreateZone(z); err != nil { log.Warnf("Unable to initialize zones for inmemory provider") } } } } // NewInMemoryProvider returns InMemoryProvider DNS provider interface implementation func NewInMemoryProvider(opts ...InMemoryOption) *InMemoryProvider { return newProvider(opts...) } // newProvider returns InMemoryProvider DNS provider interface implementation func newProvider(opts ...InMemoryOption) *InMemoryProvider { im := &InMemoryProvider{ filter: &filter{}, OnApplyChanges: func(_ context.Context, _ *plan.Changes) {}, OnRecords: func() {}, domain: endpoint.NewDomainFilter([]string{""}), client: newInMemoryClient(), } for _, opt := range opts { opt(im) } return im } // CreateZone adds new zone if not present func (im *InMemoryProvider) CreateZone(newZone string) error { return im.client.CreateZone(newZone) } // Zones returns filtered zones as specified by domain func (im *InMemoryProvider) Zones() map[string]string { return im.filter.Zones(im.client.Zones()) } // Records returns the list of endpoints func (im *InMemoryProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { defer im.OnRecords() endpoints := make([]*endpoint.Endpoint, 0) for zoneID := range im.Zones() { records, err := im.client.Records(zoneID) if err != nil { return nil, err } endpoints = append(endpoints, copyEndpoints(records)...) } return endpoints, nil } // ApplyChanges simply modifies records in memory // error checking occurs before any modifications are made, i.e. batch processing // create record - record should not exist // update/delete record - record should exist // create/update/delete lists should not have overlapping records func (im *InMemoryProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { defer im.OnApplyChanges(ctx, changes) perZoneChanges := map[string]*plan.Changes{} zones := im.Zones() for zoneID := range zones { perZoneChanges[zoneID] = &plan.Changes{} } for _, ep := range changes.Create { zoneID := im.filter.EndpointZoneID(ep, zones) if zoneID == "" { continue } perZoneChanges[zoneID].Create = append(perZoneChanges[zoneID].Create, ep) } for _, ep := range changes.UpdateNew { zoneID := im.filter.EndpointZoneID(ep, zones) if zoneID == "" { continue } perZoneChanges[zoneID].UpdateNew = append(perZoneChanges[zoneID].UpdateNew, ep) } for _, ep := range changes.UpdateOld { zoneID := im.filter.EndpointZoneID(ep, zones) if zoneID == "" { continue } perZoneChanges[zoneID].UpdateOld = append(perZoneChanges[zoneID].UpdateOld, ep) } for _, ep := range changes.Delete { zoneID := im.filter.EndpointZoneID(ep, zones) if zoneID == "" { continue } perZoneChanges[zoneID].Delete = append(perZoneChanges[zoneID].Delete, ep) } for zoneID := range perZoneChanges { change := &plan.Changes{ Create: perZoneChanges[zoneID].Create, UpdateNew: perZoneChanges[zoneID].UpdateNew, UpdateOld: perZoneChanges[zoneID].UpdateOld, Delete: perZoneChanges[zoneID].Delete, } err := im.client.ApplyChanges(ctx, zoneID, change) if err != nil { return err } } return nil } func copyEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint { records := make([]*endpoint.Endpoint, 0, len(endpoints)) for _, ep := range endpoints { newEp := endpoint.NewEndpointWithTTL(ep.DNSName, ep.RecordType, ep.RecordTTL, ep.Targets...).WithSetIdentifier(ep.SetIdentifier) newEp.Labels = endpoint.NewLabels() maps.Copy(newEp.Labels, ep.Labels) newEp.ProviderSpecific = append(endpoint.ProviderSpecific(nil), ep.ProviderSpecific...) records = append(records, newEp) } return records } type filter struct { domain string } // Zones filters map[zoneID]zoneName for names having f.domain as suffix func (f *filter) Zones(zones map[string]string) map[string]string { result := map[string]string{} for zoneID, zoneName := range zones { if strings.HasSuffix(zoneName, f.domain) { result[zoneID] = zoneName } } return result } // EndpointZoneID determines zoneID for endpoint from map[zoneID]zoneName by taking longest suffix zoneName match in endpoint DNSName // returns empty string if no match found func (f *filter) EndpointZoneID(endpoint *endpoint.Endpoint, zones map[string]string) string { var matchZoneID, matchZoneName string for zoneID, zoneName := range zones { if strings.HasSuffix(endpoint.DNSName, zoneName) && len(zoneName) > len(matchZoneName) { matchZoneName = zoneName matchZoneID = zoneID } } return matchZoneID } type zone map[endpoint.EndpointKey]*endpoint.Endpoint type inMemoryClient struct { zones map[string]zone } func newInMemoryClient() *inMemoryClient { return &inMemoryClient{map[string]zone{}} } func (c *inMemoryClient) Records(zone string) ([]*endpoint.Endpoint, error) { if _, ok := c.zones[zone]; !ok { return nil, ErrZoneNotFound } var records []*endpoint.Endpoint for _, rec := range c.zones[zone] { records = append(records, rec) } return records, nil } func (c *inMemoryClient) Zones() map[string]string { zones := map[string]string{} for zone := range c.zones { zones[zone] = zone } return zones } func (c *inMemoryClient) CreateZone(zone string) error { if _, ok := c.zones[zone]; ok { return ErrZoneAlreadyExists } c.zones[zone] = map[endpoint.EndpointKey]*endpoint.Endpoint{} return nil } func (c *inMemoryClient) ApplyChanges(_ context.Context, zoneID string, changes *plan.Changes) error { if err := c.validateChangeBatch(zoneID, changes); err != nil { return err } for _, newEndpoint := range changes.Create { c.zones[zoneID][newEndpoint.Key()] = newEndpoint } for _, updateEndpoint := range changes.UpdateNew { c.zones[zoneID][updateEndpoint.Key()] = updateEndpoint } for _, deleteEndpoint := range changes.Delete { delete(c.zones[zoneID], deleteEndpoint.Key()) } return nil } func (c *inMemoryClient) updateMesh(mesh sets.Set[endpoint.EndpointKey], record *endpoint.Endpoint) error { if mesh.Has(record.Key()) { return ErrDuplicateRecordFound } mesh.Insert(record.Key()) return nil } // validateChangeBatch validates that the changes passed to InMemory DNS provider is valid func (c *inMemoryClient) validateChangeBatch(zone string, changes *plan.Changes) error { curZone, ok := c.zones[zone] if !ok { return ErrZoneNotFound } mesh := sets.New[endpoint.EndpointKey]() for _, newEndpoint := range changes.Create { if _, ok := curZone[newEndpoint.Key()]; ok { return ErrRecordAlreadyExists } if err := c.updateMesh(mesh, newEndpoint); err != nil { return err } } for _, updateEndpoint := range changes.UpdateNew { if _, ok := curZone[updateEndpoint.Key()]; !ok { return ErrRecordNotFound } if err := c.updateMesh(mesh, updateEndpoint); err != nil { return err } } for _, updateOldEndpoint := range changes.UpdateOld { if rec, ok := curZone[updateOldEndpoint.Key()]; !ok || rec.Targets[0] != updateOldEndpoint.Targets[0] { return ErrRecordNotFound } } for _, deleteEndpoint := range changes.Delete { if rec, ok := curZone[deleteEndpoint.Key()]; !ok || rec.Targets[0] != deleteEndpoint.Targets[0] { return ErrRecordNotFound } if err := c.updateMesh(mesh, deleteEndpoint); err != nil { return err } } return nil } ================================================ FILE: provider/inmemory/inmemory_test.go ================================================ /* Copyright 2017 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 inmemory import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) var _ provider.Provider = &InMemoryProvider{} func TestInMemoryProvider(t *testing.T) { t.Run("Records", testInMemoryRecords) t.Run("validateChangeBatch", testInMemoryValidateChangeBatch) t.Run("ApplyChanges", testInMemoryApplyChanges) t.Run("NewInMemoryProvider", testNewInMemoryProvider) t.Run("CreateZone", testInMemoryCreateZone) } func testInMemoryRecords(t *testing.T) { for _, ti := range []struct { title string zone string expectError bool init map[string]zone expected []*endpoint.Endpoint }{ { title: "no records, no zone", zone: "", init: map[string]zone{}, expectError: false, }, { title: "records, wrong zone", zone: "net", init: map[string]zone{ "org": {}, "com": {}, }, expectError: false, }, { title: "records, zone with records", zone: "org", init: map[string]zone{ "org": makeZone( "example.org", "8.8.8.8", endpoint.RecordTypeA, "example.org", "", endpoint.RecordTypeTXT, "foo.org", "4.4.4.4", endpoint.RecordTypeCNAME, ), "com": makeZone("example.com", "4.4.4.4", endpoint.RecordTypeCNAME), }, expectError: false, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{""}, }, { DNSName: "foo.org", Targets: endpoint.Targets{"4.4.4.4"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, } { t.Run(ti.title, func(t *testing.T) { c := newInMemoryClient() c.zones = ti.init im := NewInMemoryProvider() im.client = c f := filter{domain: ti.zone} im.filter = &f records, err := im.Records(t.Context()) if ti.expectError { assert.Nil(t, records) assert.EqualError(t, err, ErrZoneNotFound.Error()) } else { require.NoError(t, err) assert.True(t, testutils.SameEndpoints(ti.expected, records), "Endpoints not the same: Expected: %+v Records: %+v", ti.expected, records) } }) } } func testInMemoryValidateChangeBatch(t *testing.T) { init := map[string]zone{ "org": makeZone( "example.org", "8.8.8.8", endpoint.RecordTypeA, "example.org", "", endpoint.RecordTypeTXT, "foo.org", "bar.org", endpoint.RecordTypeCNAME, "foo.bar.org", "5.5.5.5", endpoint.RecordTypeA, ), "com": makeZone("example.com", "another-example.com", endpoint.RecordTypeCNAME), } for _, ti := range []struct { title string expectError bool errorType error init map[string]zone changes *plan.Changes zone string }{ { title: "no zones, no update", expectError: true, zone: "", init: map[string]zone{}, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, errorType: ErrZoneNotFound, }, { title: "zones, no update", expectError: true, zone: "", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, errorType: ErrZoneNotFound, }, { title: "zones, update, wrong zone", expectError: true, zone: "test", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, errorType: ErrZoneNotFound, }, { title: "zones, update, right zone, invalid batch - already exists", expectError: true, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, errorType: ErrRecordAlreadyExists, }, { title: "zones, update, right zone, invalid batch - record not found for update", expectError: true, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "foo.org", Targets: endpoint.Targets{"4.4.4.4"}, RecordType: endpoint.RecordTypeA, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "foo.org", Targets: endpoint.Targets{"4.4.4.4"}, RecordType: endpoint.RecordTypeA, }, }, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, errorType: ErrRecordNotFound, }, { title: "zones, update, right zone, invalid batch - record not found for update", expectError: true, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "foo.org", Targets: endpoint.Targets{"4.4.4.4"}, RecordType: endpoint.RecordTypeA, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "foo.org", Targets: endpoint.Targets{"4.4.4.4"}, RecordType: endpoint.RecordTypeA, }, }, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, errorType: ErrRecordNotFound, }, { title: "zones, update, right zone, invalid batch - duplicated create", expectError: true, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "foo.org", Targets: endpoint.Targets{"4.4.4.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "foo.org", Targets: endpoint.Targets{"4.4.4.4"}, RecordType: endpoint.RecordTypeA, }, }, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, errorType: ErrDuplicateRecordFound, }, { title: "zones, update, right zone, invalid batch - duplicated update/delete", expectError: true, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, }, errorType: ErrDuplicateRecordFound, }, { title: "zones, update, right zone, invalid batch - duplicated update", expectError: true, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, errorType: ErrDuplicateRecordFound, }, { title: "zones, update, right zone, invalid batch - wrong update old", expectError: true, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{ { DNSName: "new.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, Delete: []*endpoint.Endpoint{}, }, errorType: ErrRecordNotFound, }, { title: "zones, update, right zone, invalid batch - wrong delete", expectError: true, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{ { DNSName: "new.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, }, errorType: ErrRecordNotFound, }, { title: "zones, update, right zone, valid batch - delete", expectError: false, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{ { DNSName: "foo.bar.org", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: endpoint.RecordTypeA, }, }, }, }, { title: "zones, update, right zone, valid batch - update and create", expectError: false, zone: "org", init: init, changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "foo.bar.new.org", Targets: endpoint.Targets{"4.8.8.9"}, RecordType: endpoint.RecordTypeA, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "foo.bar.org", Targets: endpoint.Targets{"4.8.8.4"}, RecordType: endpoint.RecordTypeA, }, }, UpdateOld: []*endpoint.Endpoint{ { DNSName: "foo.bar.org", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: endpoint.RecordTypeA, }, }, Delete: []*endpoint.Endpoint{}, }, }, } { t.Run(ti.title, func(t *testing.T) { c := &inMemoryClient{} c.zones = ti.init ichanges := &plan.Changes{ Create: ti.changes.Create, UpdateNew: ti.changes.UpdateNew, UpdateOld: ti.changes.UpdateOld, Delete: ti.changes.Delete, } err := c.validateChangeBatch(ti.zone, ichanges) if ti.expectError { assert.EqualError(t, err, ti.errorType.Error()) } else { assert.NoError(t, err) } }) } } func getInitData() map[string]zone { return map[string]zone{ "org": makeZone("example.org", "8.8.8.8", endpoint.RecordTypeA, "example.org", "", endpoint.RecordTypeTXT, "foo.org", "4.4.4.4", endpoint.RecordTypeCNAME, "foo.bar.org", "5.5.5.5", endpoint.RecordTypeA, ), "com": makeZone("example.com", "4.4.4.4", endpoint.RecordTypeCNAME), } } func testInMemoryApplyChanges(t *testing.T) { for _, ti := range []struct { title string expectError bool init map[string]zone changes *plan.Changes expectedZonesState map[string]zone }{ { title: "unmatched zone, should be ignored in the apply step", expectError: false, changes: &plan.Changes{ Create: []*endpoint.Endpoint{{ DNSName: "example.de", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }}, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{}, }, expectedZonesState: getInitData(), }, { title: "expect error", expectError: true, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, }, }, { title: "zones, update, right zone, valid batch - delete", expectError: false, changes: &plan.Changes{ Create: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, Delete: []*endpoint.Endpoint{ { DNSName: "foo.bar.org", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: endpoint.RecordTypeA, }, }, }, expectedZonesState: map[string]zone{ "org": makeZone("example.org", "8.8.8.8", endpoint.RecordTypeA, "example.org", "", endpoint.RecordTypeTXT, "foo.org", "4.4.4.4", endpoint.RecordTypeCNAME, ), "com": makeZone("example.com", "4.4.4.4", endpoint.RecordTypeCNAME), }, }, { title: "zones, update, right zone, valid batch - update, create, delete", expectError: false, changes: &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "foo.bar.new.org", Targets: endpoint.Targets{"4.8.8.9"}, RecordType: endpoint.RecordTypeA, Labels: endpoint.NewLabels(), }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "foo.bar.org", Targets: endpoint.Targets{"4.8.8.4"}, RecordType: endpoint.RecordTypeA, Labels: endpoint.NewLabels(), }, }, UpdateOld: []*endpoint.Endpoint{ { DNSName: "foo.bar.org", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: endpoint.RecordTypeA, Labels: endpoint.NewLabels(), }, }, Delete: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, Labels: endpoint.NewLabels(), }, }, }, expectedZonesState: map[string]zone{ "org": makeZone( "example.org", "", endpoint.RecordTypeTXT, "foo.org", "4.4.4.4", endpoint.RecordTypeCNAME, "foo.bar.org", "4.8.8.4", endpoint.RecordTypeA, "foo.bar.new.org", "4.8.8.9", endpoint.RecordTypeA, ), "com": makeZone("example.com", "4.4.4.4", endpoint.RecordTypeCNAME), }, }, } { t.Run(ti.title, func(t *testing.T) { im := NewInMemoryProvider() c := &inMemoryClient{} c.zones = getInitData() im.client = c err := im.ApplyChanges(t.Context(), ti.changes) if ti.expectError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, ti.expectedZonesState, c.zones) } }) } } func testNewInMemoryProvider(t *testing.T) { cfg := NewInMemoryProvider() assert.NotNil(t, cfg.client) } func testInMemoryCreateZone(t *testing.T) { im := NewInMemoryProvider() err := im.CreateZone("zone") require.NoError(t, err) err = im.CreateZone("zone") require.EqualError(t, err, ErrZoneAlreadyExists.Error()) } func makeZone(s ...string) map[endpoint.EndpointKey]*endpoint.Endpoint { if len(s)%3 != 0 { panic("makeZone arguments must be multiple of 3") } output := map[endpoint.EndpointKey]*endpoint.Endpoint{} for i := 0; i < len(s); i += 3 { ep := endpoint.NewEndpoint(s[i], s[i+2], s[i+1]) output[ep.Key()] = ep } return output } ================================================ FILE: provider/linode/linode.go ================================================ /* Copyright 2017 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 linode import ( "context" "fmt" "net/http" "os" "strconv" "strings" "github.com/linode/linodego" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) // LinodeDomainClient interface to ease testing type LinodeDomainClient interface { ListDomainRecords(ctx context.Context, domainID int, opts *linodego.ListOptions) ([]linodego.DomainRecord, error) ListDomains(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Domain, error) CreateDomainRecord(ctx context.Context, domainID int, domainrecord linodego.DomainRecordCreateOptions) (*linodego.DomainRecord, error) DeleteDomainRecord(ctx context.Context, domainID int, id int) error UpdateDomainRecord(ctx context.Context, domainID int, id int, domainrecord linodego.DomainRecordUpdateOptions) (*linodego.DomainRecord, error) } // LinodeProvider is an implementation of Provider for Digital Ocean's DNS. type LinodeProvider struct { provider.BaseProvider Client LinodeDomainClient domainFilter *endpoint.DomainFilter DryRun bool } // LinodeChanges All API calls calculated from the plan type LinodeChanges struct { Creates []LinodeChangeCreate Deletes []LinodeChangeDelete Updates []LinodeChangeUpdate } // LinodeChangeCreate Linode Domain Record Creates type LinodeChangeCreate struct { Domain linodego.Domain Options linodego.DomainRecordCreateOptions } // LinodeChangeUpdate Linode Domain Record Updates type LinodeChangeUpdate struct { Domain linodego.Domain DomainRecord linodego.DomainRecord Options linodego.DomainRecordUpdateOptions } // LinodeChangeDelete Linode Domain Record Deletes type LinodeChangeDelete struct { Domain linodego.Domain DomainRecord linodego.DomainRecord } // New creates a Linode provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(domainFilter, cfg.DryRun) } // newProvider initializes a new Linode DNS based Provider. func newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*LinodeProvider, error) { token, ok := os.LookupEnv("LINODE_TOKEN") if !ok { return nil, fmt.Errorf("no token found") } tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) oauth2Client := &http.Client{ Transport: &oauth2.Transport{ Source: tokenSource, }, } linodeClient := linodego.NewClient(oauth2Client) linodeClient.SetUserAgent(fmt.Sprintf("%s linodego/%s", externaldns.UserAgent(), linodego.Version)) return &LinodeProvider{ Client: &linodeClient, domainFilter: domainFilter, DryRun: dryRun, }, nil } // Zones return the list of hosted zones. func (p *LinodeProvider) Zones(ctx context.Context) ([]linodego.Domain, error) { zones, err := p.fetchZones(ctx) if err != nil { return nil, err } return zones, nil } // Records returns the list of records in a given zone. func (p *LinodeProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.Zones(ctx) if err != nil { return nil, err } var endpoints []*endpoint.Endpoint for _, zone := range zones { records, err := p.fetchRecords(ctx, zone.ID) if err != nil { return nil, err } for _, r := range records { if provider.SupportedRecordType(string(r.Type)) { name := fmt.Sprintf("%s.%s", r.Name, zone.Domain) // root name is identified by the empty string and should be // translated to zone name for the endpoint entry. if r.Name == "" { name = zone.Domain } endpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, string(r.Type), endpoint.TTL(r.TTLSec), r.Target)) } } } return endpoints, nil } func (p *LinodeProvider) fetchRecords(ctx context.Context, domainID int) ([]linodego.DomainRecord, error) { records, err := p.Client.ListDomainRecords(ctx, domainID, nil) if err != nil { return nil, err } return records, nil } func (p *LinodeProvider) fetchZones(ctx context.Context) ([]linodego.Domain, error) { var zones []linodego.Domain allZones, err := p.Client.ListDomains(ctx, linodego.NewListOptions(0, "")) if err != nil { return nil, err } for _, zone := range allZones { if !p.domainFilter.Match(zone.Domain) { continue } zones = append(zones, zone) } return zones, nil } // submitChanges takes a zone and a collection of Changes and sends them as a single transaction. func (p *LinodeProvider) submitChanges(ctx context.Context, changes LinodeChanges) error { for _, change := range changes.Creates { logFields := log.Fields{ "record": change.Options.Name, "type": change.Options.Type, "action": "Create", "zoneName": change.Domain.Domain, "zoneID": change.Domain.ID, } log.WithFields(logFields).Info("Creating record.") if p.DryRun { log.WithFields(logFields).Info("Would create record.") } else if _, err := p.Client.CreateDomainRecord(ctx, change.Domain.ID, change.Options); err != nil { log.WithFields(logFields).Errorf( "Failed to Create record: %v", err, ) } } for _, change := range changes.Deletes { logFields := log.Fields{ "record": change.DomainRecord.Name, "type": change.DomainRecord.Type, "action": "Delete", "zoneName": change.Domain.Domain, "zoneID": change.Domain.ID, } log.WithFields(logFields).Info("Deleting record.") if p.DryRun { log.WithFields(logFields).Info("Would delete record.") } else if err := p.Client.DeleteDomainRecord(ctx, change.Domain.ID, change.DomainRecord.ID); err != nil { log.WithFields(logFields).Errorf( "Failed to Delete record: %v", err, ) } } for _, change := range changes.Updates { logFields := log.Fields{ "record": change.Options.Name, "type": change.Options.Type, "action": "Update", "zoneName": change.Domain.Domain, "zoneID": change.Domain.ID, } log.WithFields(logFields).Info("Updating record.") if p.DryRun { log.WithFields(logFields).Info("Would update record.") } else if _, err := p.Client.UpdateDomainRecord(ctx, change.Domain.ID, change.DomainRecord.ID, change.Options); err != nil { log.WithFields(logFields).Errorf( "Failed to Update record: %v", err, ) } } return nil } func getWeight(recordType linodego.DomainRecordType) *int { weight := 1 // NS records do not support having weight if recordType == linodego.RecordTypeNS { weight = 0 } return &weight } func getPort() *int { port := 0 return &port } func getPriority() *int { priority := 0 return &priority } // ApplyChanges applies a given set of changes in a given zone. func (p *LinodeProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { recordsByZoneID := make(map[string][]linodego.DomainRecord) zones, err := p.fetchZones(ctx) if err != nil { return err } zonesByID := make(map[string]linodego.Domain) zoneNameIDMapper := provider.ZoneIDName{} for _, z := range zones { zoneNameIDMapper.Add(strconv.Itoa(z.ID), z.Domain) zonesByID[strconv.Itoa(z.ID)] = z } // Fetch records for each zone for _, zone := range zones { records, err := p.fetchRecords(ctx, zone.ID) if err != nil { return err } recordsByZoneID[strconv.Itoa(zone.ID)] = append(recordsByZoneID[strconv.Itoa(zone.ID)], records...) } createsByZone := endpointsByZone(zoneNameIDMapper, changes.Create) updatesByZone := endpointsByZone(zoneNameIDMapper, changes.UpdateNew) deletesByZone := endpointsByZone(zoneNameIDMapper, changes.Delete) var linodeCreates []LinodeChangeCreate var linodeUpdates []LinodeChangeUpdate var linodeDeletes []LinodeChangeDelete // Generate Creates for zoneID, creates := range createsByZone { zone := zonesByID[zoneID] if len(creates) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "zoneName": zone.Domain, }).Debug("Skipping Zone, no creates found.") continue } records := recordsByZoneID[zoneID] for _, ep := range creates { matchedRecords := getRecordID(records, zone, ep) if len(matchedRecords) != 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "zoneName": zone.Domain, "dnsName": ep.DNSName, "recordType": ep.RecordType, }).Warn("Records found which should not exist. Not touching it.") continue } recordType, err := convertRecordType(ep.RecordType) if err != nil { return err } for _, target := range ep.Targets { linodeCreates = append(linodeCreates, LinodeChangeCreate{ Domain: zone, Options: linodego.DomainRecordCreateOptions{ Target: target, Name: getStrippedRecordName(zone, ep), Type: recordType, Weight: getWeight(recordType), Port: getPort(), Priority: getPriority(), TTLSec: int(ep.RecordTTL), }, }) } } } // Generate Updates for zoneID, updates := range updatesByZone { zone := zonesByID[zoneID] if len(updates) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "zoneName": zone.Domain, }).Debug("Skipping Zone, no updates found.") continue } records := recordsByZoneID[zoneID] for _, ep := range updates { matchedRecords := getRecordID(records, zone, ep) if len(matchedRecords) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Domain, "recordType": ep.RecordType, }).Warn("Update Records not found.") } recordType, err := convertRecordType(ep.RecordType) if err != nil { return err } matchedRecordsByTarget := make(map[string]linodego.DomainRecord) for _, record := range matchedRecords { matchedRecordsByTarget[record.Target] = record } for _, target := range ep.Targets { if record, ok := matchedRecordsByTarget[target]; ok { log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Domain, "recordType": ep.RecordType, "target": target, }).Warn("Updating Existing Target") linodeUpdates = append(linodeUpdates, LinodeChangeUpdate{ Domain: zone, DomainRecord: record, Options: linodego.DomainRecordUpdateOptions{ Target: target, Name: getStrippedRecordName(zone, ep), Type: recordType, Weight: getWeight(recordType), Port: getPort(), Priority: getPriority(), TTLSec: int(ep.RecordTTL), }, }) delete(matchedRecordsByTarget, target) } else { // Record did not previously exist, create new 'target' log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Domain, "recordType": ep.RecordType, "target": target, }).Warn("Creating New Target") linodeCreates = append(linodeCreates, LinodeChangeCreate{ Domain: zone, Options: linodego.DomainRecordCreateOptions{ Target: target, Name: getStrippedRecordName(zone, ep), Type: recordType, Weight: getWeight(recordType), Port: getPort(), Priority: getPriority(), TTLSec: int(ep.RecordTTL), }, }) } } // Any remaining records have been removed, delete them for _, record := range matchedRecordsByTarget { log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Domain, "recordType": ep.RecordType, "target": record.Target, }).Warn("Deleting Target") linodeDeletes = append(linodeDeletes, LinodeChangeDelete{ Domain: zone, DomainRecord: record, }) } } } // Generate Deletes for zoneID, deletes := range deletesByZone { zone := zonesByID[zoneID] if len(deletes) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "zoneName": zone.Domain, }).Debug("Skipping Zone, no deletes found.") continue } records := recordsByZoneID[zoneID] for _, ep := range deletes { matchedRecords := getRecordID(records, zone, ep) if len(matchedRecords) == 0 { log.WithFields(log.Fields{ "zoneID": zoneID, "dnsName": ep.DNSName, "zoneName": zone.Domain, "recordType": ep.RecordType, }).Warn("Records to Delete not found.") } for _, record := range matchedRecords { linodeDeletes = append(linodeDeletes, LinodeChangeDelete{ Domain: zone, DomainRecord: record, }) } } } return p.submitChanges(ctx, LinodeChanges{ Creates: linodeCreates, Deletes: linodeDeletes, Updates: linodeUpdates, }) } func endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]endpoint.Endpoint { endpointsByZone := make(map[string][]endpoint.Endpoint) for _, ep := range endpoints { zoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName) if zoneID == "" { log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", ep.DNSName) continue } endpointsByZone[zoneID] = append(endpointsByZone[zoneID], *ep) } return endpointsByZone } func convertRecordType(recordType string) (linodego.DomainRecordType, error) { switch recordType { case "A": return linodego.RecordTypeA, nil case "AAAA": return linodego.RecordTypeAAAA, nil case "CNAME": return linodego.RecordTypeCNAME, nil case "TXT": return linodego.RecordTypeTXT, nil case "SRV": return linodego.RecordTypeSRV, nil case "NS": return linodego.RecordTypeNS, nil default: return "", fmt.Errorf("invalid Record Type: %s", recordType) } } func getStrippedRecordName(zone linodego.Domain, ep endpoint.Endpoint) string { // Handle root if ep.DNSName == zone.Domain { return "" } return strings.TrimSuffix(ep.DNSName, "."+zone.Domain) } func getRecordID(records []linodego.DomainRecord, zone linodego.Domain, ep endpoint.Endpoint) []linodego.DomainRecord { var matchedRecords []linodego.DomainRecord for _, record := range records { if record.Name == getStrippedRecordName(zone, ep) && string(record.Type) == ep.RecordType { matchedRecords = append(matchedRecords, record) } } return matchedRecords } ================================================ FILE: provider/linode/linode_test.go ================================================ /* Copyright 2017 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 linode import ( "context" "os" "testing" "github.com/linode/linodego" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) type MockDomainClient struct { mock.Mock } func (m *MockDomainClient) ListDomainRecords(ctx context.Context, domainID int, opts *linodego.ListOptions) ([]linodego.DomainRecord, error) { args := m.Called(ctx, domainID, opts) return args.Get(0).([]linodego.DomainRecord), args.Error(1) } func (m *MockDomainClient) ListDomains(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Domain, error) { args := m.Called(ctx, opts) return args.Get(0).([]linodego.Domain), args.Error(1) } func (m *MockDomainClient) CreateDomainRecord(ctx context.Context, domainID int, opts linodego.DomainRecordCreateOptions) (*linodego.DomainRecord, error) { args := m.Called(ctx, domainID, opts) return args.Get(0).(*linodego.DomainRecord), args.Error(1) } func (m *MockDomainClient) DeleteDomainRecord(ctx context.Context, domainID int, recordID int) error { args := m.Called(ctx, domainID, recordID) return args.Error(0) } func (m *MockDomainClient) UpdateDomainRecord(ctx context.Context, domainID int, recordID int, opts linodego.DomainRecordUpdateOptions) (*linodego.DomainRecord, error) { args := m.Called(ctx, domainID, recordID, opts) return args.Get(0).(*linodego.DomainRecord), args.Error(1) } func createZones() []linodego.Domain { return []linodego.Domain{ {ID: 1, Domain: "foo.com"}, {ID: 2, Domain: "bar.io"}, {ID: 3, Domain: "baz.com"}, } } func createFooRecords() []linodego.DomainRecord { return []linodego.DomainRecord{{ ID: 11, Type: linodego.RecordTypeA, Name: "", Target: "targetFoo", }, { ID: 12, Type: linodego.RecordTypeTXT, Name: "", Target: "txt", }, { ID: 13, Type: linodego.RecordTypeCAA, Name: "foo.com", Target: "", }} } func createBarRecords() []linodego.DomainRecord { return []linodego.DomainRecord{} } func createBazRecords() []linodego.DomainRecord { return []linodego.DomainRecord{{ ID: 31, Type: linodego.RecordTypeA, Name: "", Target: "targetBaz", }, { ID: 32, Type: linodego.RecordTypeTXT, Name: "", Target: "txt", }, { ID: 33, Type: linodego.RecordTypeA, Name: "api", Target: "targetBaz", }, { ID: 34, Type: linodego.RecordTypeTXT, Name: "api", Target: "txt", }} } func TestLinodeConvertRecordType(t *testing.T) { record, err := convertRecordType("A") require.NoError(t, err) assert.Equal(t, linodego.RecordTypeA, record) record, err = convertRecordType("AAAA") require.NoError(t, err) assert.Equal(t, linodego.RecordTypeAAAA, record) record, err = convertRecordType("CNAME") require.NoError(t, err) assert.Equal(t, linodego.RecordTypeCNAME, record) record, err = convertRecordType("TXT") require.NoError(t, err) assert.Equal(t, linodego.RecordTypeTXT, record) record, err = convertRecordType("SRV") require.NoError(t, err) assert.Equal(t, linodego.RecordTypeSRV, record) record, err = convertRecordType("NS") require.NoError(t, err) assert.Equal(t, linodego.RecordTypeNS, record) _, err = convertRecordType("INVALID") require.Error(t, err) } func TestNewProvider(t *testing.T) { t.Setenv("LINODE_TOKEN", "xxxxxxxxxxxxxxxxx") _, err := newProvider(endpoint.NewDomainFilter([]string{"ext-dns-test.zalando.to."}), true) require.NoError(t, err) _ = os.Unsetenv("LINODE_TOKEN") _, err = newProvider(endpoint.NewDomainFilter([]string{"ext-dns-test.zalando.to."}), true) require.Error(t, err) } func TestLinodeStripRecordName(t *testing.T) { assert.Equal(t, "api", getStrippedRecordName(linodego.Domain{ Domain: "example.com", }, endpoint.Endpoint{ DNSName: "api.example.com", })) assert.Empty(t, getStrippedRecordName(linodego.Domain{ Domain: "example.com", }, endpoint.Endpoint{ DNSName: "example.com", })) } func TestLinodeFetchZonesNoFilters(t *testing.T) { mockDomainClient := MockDomainClient{} provider := &LinodeProvider{ Client: &mockDomainClient, domainFilter: endpoint.NewDomainFilter([]string{}), DryRun: false, } mockDomainClient.On( "ListDomains", mock.Anything, mock.Anything, ).Return(createZones(), nil).Once() expected := createZones() actual, err := provider.fetchZones(t.Context()) require.NoError(t, err) mockDomainClient.AssertExpectations(t) assert.Equal(t, expected, actual) } func TestLinodeFetchZonesWithFilter(t *testing.T) { mockDomainClient := MockDomainClient{} provider := &LinodeProvider{ Client: &mockDomainClient, domainFilter: endpoint.NewDomainFilter([]string{".com"}), DryRun: false, } mockDomainClient.On( "ListDomains", mock.Anything, mock.Anything, ).Return(createZones(), nil).Once() expected := []linodego.Domain{ {ID: 1, Domain: "foo.com"}, {ID: 3, Domain: "baz.com"}, } actual, err := provider.fetchZones(t.Context()) require.NoError(t, err) mockDomainClient.AssertExpectations(t) assert.Equal(t, expected, actual) } func TestLinodeGetStrippedRecordName(t *testing.T) { assert.Empty(t, getStrippedRecordName(linodego.Domain{ Domain: "foo.com", }, endpoint.Endpoint{ DNSName: "foo.com", })) assert.Equal(t, "api", getStrippedRecordName(linodego.Domain{ Domain: "foo.com", }, endpoint.Endpoint{ DNSName: "api.foo.com", })) } func TestLinodeRecords(t *testing.T) { mockDomainClient := MockDomainClient{} provider := &LinodeProvider{ Client: &mockDomainClient, domainFilter: endpoint.NewDomainFilter([]string{}), DryRun: false, } mockDomainClient.On( "ListDomains", mock.Anything, mock.Anything, ).Return(createZones(), nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 1, mock.Anything, ).Return(createFooRecords(), nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 2, mock.Anything, ).Return(createBarRecords(), nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 3, mock.Anything, ).Return(createBazRecords(), nil).Once() actual, err := provider.Records(t.Context()) require.NoError(t, err) expected := []*endpoint.Endpoint{ {DNSName: "foo.com", Targets: []string{"targetFoo"}, RecordType: "A", RecordTTL: 0, Labels: endpoint.NewLabels()}, {DNSName: "foo.com", Targets: []string{"txt"}, RecordType: "TXT", RecordTTL: 0, Labels: endpoint.NewLabels()}, {DNSName: "baz.com", Targets: []string{"targetBaz"}, RecordType: "A", RecordTTL: 0, Labels: endpoint.NewLabels()}, {DNSName: "baz.com", Targets: []string{"txt"}, RecordType: "TXT", RecordTTL: 0, Labels: endpoint.NewLabels()}, {DNSName: "api.baz.com", Targets: []string{"targetBaz"}, RecordType: "A", RecordTTL: 0, Labels: endpoint.NewLabels()}, {DNSName: "api.baz.com", Targets: []string{"txt"}, RecordType: "TXT", RecordTTL: 0, Labels: endpoint.NewLabels()}, } mockDomainClient.AssertExpectations(t) assert.Equal(t, expected, actual) } func TestLinodeApplyChanges(t *testing.T) { mockDomainClient := MockDomainClient{} provider := &LinodeProvider{ Client: &mockDomainClient, domainFilter: endpoint.NewDomainFilter([]string{}), DryRun: false, } // Dummy Data mockDomainClient.On( "ListDomains", mock.Anything, mock.Anything, ).Return(createZones(), nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 1, mock.Anything, ).Return(createFooRecords(), nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 2, mock.Anything, ).Return(createBarRecords(), nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 3, mock.Anything, ).Return(createBazRecords(), nil).Once() // Apply Actions mockDomainClient.On( "DeleteDomainRecord", mock.Anything, 3, 33, ).Return(nil).Once() mockDomainClient.On( "DeleteDomainRecord", mock.Anything, 3, 34, ).Return(nil).Once() mockDomainClient.On( "UpdateDomainRecord", mock.Anything, 1, 11, linodego.DomainRecordUpdateOptions{ Type: "A", Name: "", Target: "targetFoo", Priority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), TTLSec: 300, }, ).Return(&linodego.DomainRecord{}, nil).Once() mockDomainClient.On( "CreateDomainRecord", mock.Anything, 2, linodego.DomainRecordCreateOptions{ Type: "A", Name: "create", Target: "targetBar", Priority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), TTLSec: 0, }, ).Return(&linodego.DomainRecord{}, nil).Once() mockDomainClient.On( "CreateDomainRecord", mock.Anything, 2, linodego.DomainRecordCreateOptions{ Type: "A", Name: "", Target: "targetBar", Priority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), TTLSec: 0, }, ).Return(&linodego.DomainRecord{}, nil).Once() err := provider.ApplyChanges(t.Context(), &plan.Changes{ Create: []*endpoint.Endpoint{{ DNSName: "create.bar.io", RecordType: "A", Targets: []string{"targetBar"}, }, { DNSName: "bar.io", RecordType: "A", Targets: []string{"targetBar"}, }, { // This record should be skipped as it already exists DNSName: "foo.com", RecordType: "TXT", Targets: []string{"txt"}, }}, Delete: []*endpoint.Endpoint{{ DNSName: "api.baz.com", RecordType: "A", }, { DNSName: "api.baz.com", RecordType: "TXT", }}, UpdateNew: []*endpoint.Endpoint{{ DNSName: "foo.com", RecordType: "A", RecordTTL: 300, Targets: []string{"targetFoo"}, }}, UpdateOld: []*endpoint.Endpoint{}, }) require.NoError(t, err) mockDomainClient.AssertExpectations(t) } func TestLinodeApplyChangesTargetAdded(t *testing.T) { mockDomainClient := MockDomainClient{} provider := &LinodeProvider{ Client: &mockDomainClient, domainFilter: endpoint.NewDomainFilter([]string{}), DryRun: false, } // Dummy Data mockDomainClient.On( "ListDomains", mock.Anything, mock.Anything, ).Return([]linodego.Domain{{Domain: "example.com", ID: 1}}, nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 1, mock.Anything, ).Return([]linodego.DomainRecord{{ID: 11, Name: "", Type: "A", Target: "targetA"}}, nil).Once() // Apply Actions mockDomainClient.On( "UpdateDomainRecord", mock.Anything, 1, 11, linodego.DomainRecordUpdateOptions{ Type: "A", Name: "", Target: "targetA", Priority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), }, ).Return(&linodego.DomainRecord{}, nil).Once() mockDomainClient.On( "CreateDomainRecord", mock.Anything, 1, linodego.DomainRecordCreateOptions{ Type: "A", Name: "", Target: "targetB", Priority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), }, ).Return(&linodego.DomainRecord{}, nil).Once() err := provider.ApplyChanges(t.Context(), &plan.Changes{ // From 1 target to 2 UpdateNew: []*endpoint.Endpoint{{ DNSName: "example.com", RecordType: "A", Targets: []string{"targetA", "targetB"}, }}, UpdateOld: []*endpoint.Endpoint{}, }) require.NoError(t, err) mockDomainClient.AssertExpectations(t) } func TestLinodeApplyChangesTargetRemoved(t *testing.T) { mockDomainClient := MockDomainClient{} provider := &LinodeProvider{ Client: &mockDomainClient, domainFilter: endpoint.NewDomainFilter([]string{}), DryRun: false, } // Dummy Data mockDomainClient.On( "ListDomains", mock.Anything, mock.Anything, ).Return([]linodego.Domain{{Domain: "example.com", ID: 1}}, nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 1, mock.Anything, ).Return([]linodego.DomainRecord{{ID: 11, Name: "", Type: "A", Target: "targetA"}, {ID: 12, Type: "A", Name: "", Target: "targetB"}}, nil).Once() // Apply Actions mockDomainClient.On( "UpdateDomainRecord", mock.Anything, 1, 12, linodego.DomainRecordUpdateOptions{ Type: "A", Name: "", Target: "targetB", Priority: getPriority(), Weight: getWeight(linodego.RecordTypeA), Port: getPort(), }, ).Return(&linodego.DomainRecord{}, nil).Once() mockDomainClient.On( "DeleteDomainRecord", mock.Anything, 1, 11, ).Return(nil).Once() err := provider.ApplyChanges(t.Context(), &plan.Changes{ // From 2 targets to 1 UpdateNew: []*endpoint.Endpoint{{ DNSName: "example.com", RecordType: "A", Targets: []string{"targetB"}, }}, UpdateOld: []*endpoint.Endpoint{}, }) require.NoError(t, err) mockDomainClient.AssertExpectations(t) } func TestLinodeApplyChangesNoChanges(t *testing.T) { mockDomainClient := MockDomainClient{} provider := &LinodeProvider{ Client: &mockDomainClient, domainFilter: endpoint.NewDomainFilter([]string{}), DryRun: false, } // Dummy Data mockDomainClient.On( "ListDomains", mock.Anything, mock.Anything, ).Return([]linodego.Domain{{Domain: "example.com", ID: 1}}, nil).Once() mockDomainClient.On( "ListDomainRecords", mock.Anything, 1, mock.Anything, ).Return([]linodego.DomainRecord{{ID: 11, Name: "", Type: "A", Target: "targetA"}}, nil).Once() err := provider.ApplyChanges(t.Context(), &plan.Changes{}) require.NoError(t, err) mockDomainClient.AssertExpectations(t) } ================================================ FILE: provider/ns1/ns1.go ================================================ /* Copyright 2017 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 ns1 import ( "context" "crypto/tls" "fmt" "net/http" "os" "strings" log "github.com/sirupsen/logrus" api "gopkg.in/ns1/ns1-go.v2/rest" "gopkg.in/ns1/ns1-go.v2/rest/model/dns" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( // ns1Create is a ChangeAction enum value ns1Create = "CREATE" // ns1Delete is a ChangeAction enum value ns1Delete = "DELETE" // ns1Update is a ChangeAction enum value ns1Update = "UPDATE" // defaultTTL is the default ttl for ttls that are not set defaultTTL = 10 ) // NS1DomainClient is a subset of the NS1 API the provider uses, to ease testing type NS1DomainClient interface { CreateRecord(r *dns.Record) (*http.Response, error) DeleteRecord(zone string, domain string, t string) (*http.Response, error) UpdateRecord(r *dns.Record) (*http.Response, error) GetZone(zone string) (*dns.Zone, *http.Response, error) ListZones() ([]*dns.Zone, *http.Response, error) } // NS1DomainService wraps the API and fulfills the NS1DomainClient interface type NS1DomainService struct { service *api.Client } // CreateRecord wraps the Create method of the API's Record service func (n NS1DomainService) CreateRecord(r *dns.Record) (*http.Response, error) { return n.service.Records.Create(r) } // DeleteRecord wraps the Delete method of the API's Record service func (n NS1DomainService) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { return n.service.Records.Delete(zone, domain, t) } // UpdateRecord wraps the Update method of the API's Record service func (n NS1DomainService) UpdateRecord(r *dns.Record) (*http.Response, error) { return n.service.Records.Update(r) } // GetZone wraps the Get method of the API's Zones service func (n NS1DomainService) GetZone(zone string) (*dns.Zone, *http.Response, error) { return n.service.Zones.Get(zone, true) } // ListZones wraps the List method of the API's Zones service func (n NS1DomainService) ListZones() ([]*dns.Zone, *http.Response, error) { return n.service.Zones.List() } // NS1Config passes cli args to the NS1Provider type NS1Config struct { DomainFilter *endpoint.DomainFilter ZoneIDFilter provider.ZoneIDFilter NS1Endpoint string NS1IgnoreSSL bool DryRun bool MinTTLSeconds int } // NS1Provider is the NS1 provider type NS1Provider struct { provider.BaseProvider client NS1DomainClient domainFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter dryRun bool minTTLSeconds int } // New creates an NS1 provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider( NS1Config{ DomainFilter: domainFilter, ZoneIDFilter: provider.NewZoneIDFilter(cfg.ZoneIDFilter), NS1Endpoint: cfg.NS1Endpoint, NS1IgnoreSSL: cfg.NS1IgnoreSSL, DryRun: cfg.DryRun, MinTTLSeconds: cfg.NS1MinTTLSeconds, }, ) } // newProvider creates a new NS1 Provider func newProvider(config NS1Config) (*NS1Provider, error) { return newNS1ProviderWithHTTPClient(config, http.DefaultClient) } func newNS1ProviderWithHTTPClient(config NS1Config, client *http.Client) (*NS1Provider, error) { token, ok := os.LookupEnv("NS1_APIKEY") if !ok { return nil, fmt.Errorf("NS1_APIKEY environment variable is not set") } clientArgs := []func(*api.Client){api.SetAPIKey(token)} if config.NS1Endpoint != "" { log.Infof("ns1-endpoint flag is set, targeting endpoint at %s", config.NS1Endpoint) clientArgs = append(clientArgs, api.SetEndpoint(config.NS1Endpoint)) } if config.NS1IgnoreSSL { log.Info("ns1-ignoressl flag is True, skipping SSL verification") defaultTransport := http.DefaultTransport.(*http.Transport) tr := &http.Transport{ Proxy: defaultTransport.Proxy, DialContext: defaultTransport.DialContext, MaxIdleConns: defaultTransport.MaxIdleConns, IdleConnTimeout: defaultTransport.IdleConnTimeout, ExpectContinueTimeout: defaultTransport.ExpectContinueTimeout, TLSHandshakeTimeout: defaultTransport.TLSHandshakeTimeout, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } client.Transport = tr } apiClient := api.NewClient(client, clientArgs...) return &NS1Provider{ client: NS1DomainService{apiClient}, domainFilter: config.DomainFilter, zoneIDFilter: config.ZoneIDFilter, minTTLSeconds: config.MinTTLSeconds, }, nil } // Records returns the endpoints this provider knows about func (p *NS1Provider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.zonesFiltered() if err != nil { return nil, err } var endpoints []*endpoint.Endpoint for _, zone := range zones { // TODO handle Header Codes zoneData, _, err := p.client.GetZone(zone.String()) if err != nil { return nil, err } for _, record := range zoneData.Records { if provider.SupportedRecordType(record.Type) { endpoints = append(endpoints, endpoint.NewEndpointWithTTL( record.Domain, record.Type, endpoint.TTL(record.TTL), record.ShortAns..., ), ) } } } return endpoints, nil } // ns1BuildRecord returns a dns.Record for a change set func (p *NS1Provider) ns1BuildRecord(zoneName string, change *ns1Change) *dns.Record { record := dns.NewRecord(zoneName, change.Endpoint.DNSName, change.Endpoint.RecordType, map[string]string{}, []string{}) for _, v := range change.Endpoint.Targets { record.AddAnswer(dns.NewAnswer(strings.Split(v, " "))) } // set default ttl, but respect minTTLSeconds ttl := max(p.minTTLSeconds, defaultTTL) if change.Endpoint.RecordTTL.IsConfigured() { ttl = int(change.Endpoint.RecordTTL) } record.TTL = ttl return record } // ns1SubmitChanges takes an array of changes and sends them to NS1 func (p *NS1Provider) ns1SubmitChanges(changes []*ns1Change) error { // return early if there is nothing to change if len(changes) == 0 { return nil } zones, err := p.zonesFiltered() if err != nil { return err } // separate into per-zone change sets to be passed to the API. changesByZone := ns1ChangesByZone(zones, changes) for zoneName, changes := range changesByZone { for _, change := range changes { record := p.ns1BuildRecord(zoneName, change) logFields := log.Fields{ "record": record.Domain, "type": record.Type, "ttl": record.TTL, "action": change.Action, "zone": zoneName, } log.WithFields(logFields).Info("Changing record.") if p.dryRun { continue } switch change.Action { case ns1Create: _, err := p.client.CreateRecord(record) if err != nil { return err } case ns1Delete: _, err := p.client.DeleteRecord(zoneName, record.Domain, record.Type) if err != nil { return err } case ns1Update: _, err := p.client.UpdateRecord(record) if err != nil { return err } } } } return nil } // Zones returns the list of hosted zones. func (p *NS1Provider) zonesFiltered() ([]*dns.Zone, error) { // TODO handle Header Codes zones, _, err := p.client.ListZones() if err != nil { return nil, err } var toReturn []*dns.Zone for _, z := range zones { if p.domainFilter.Match(z.Zone) && p.zoneIDFilter.Match(z.ID) { toReturn = append(toReturn, z) log.Debugf("Matched %s", z.Zone) } else { log.Debugf("Filtered %s", z.Zone) } } return toReturn, nil } // ns1Change differentiates between ChangeActions type ns1Change struct { Action string Endpoint *endpoint.Endpoint } // ApplyChanges applies a given set of changes in a given zone. func (p *NS1Provider) ApplyChanges(_ context.Context, changes *plan.Changes) error { combinedChanges := make([]*ns1Change, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) combinedChanges = append(combinedChanges, newNS1Changes(ns1Create, changes.Create)...) combinedChanges = append(combinedChanges, newNS1Changes(ns1Update, changes.UpdateNew)...) combinedChanges = append(combinedChanges, newNS1Changes(ns1Delete, changes.Delete)...) return p.ns1SubmitChanges(combinedChanges) } // newNS1Changes returns a collection of Changes based on the given records and action. func newNS1Changes(action string, endpoints []*endpoint.Endpoint) []*ns1Change { changes := make([]*ns1Change, 0, len(endpoints)) for _, ep := range endpoints { changes = append(changes, &ns1Change{ Action: action, Endpoint: ep, }, ) } return changes } // ns1ChangesByZone separates a multi-zone change into a single change per zone. func ns1ChangesByZone(zones []*dns.Zone, changeSets []*ns1Change) map[string][]*ns1Change { changes := make(map[string][]*ns1Change) zoneNameIDMapper := provider.ZoneIDName{} for _, z := range zones { zoneNameIDMapper.Add(z.Zone, z.Zone) changes[z.Zone] = []*ns1Change{} } for _, c := range changeSets { zone, _ := zoneNameIDMapper.FindZone(c.Endpoint.DNSName) if zone == "" { log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", c.Endpoint.DNSName) continue } changes[zone] = append(changes[zone], c) } return changes } ================================================ FILE: provider/ns1/ns1_test.go ================================================ /* Copyright 2017 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 ns1 import ( "fmt" "net/http" "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" api "gopkg.in/ns1/ns1-go.v2/rest" "gopkg.in/ns1/ns1-go.v2/rest/model/dns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) type MockNS1DomainClient struct { mock.Mock } func (m *MockNS1DomainClient) CreateRecord(_ *dns.Record) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1DomainClient) DeleteRecord(_ string, _ string, _ string) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1DomainClient) UpdateRecord(_ *dns.Record) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1DomainClient) GetZone(zone string) (*dns.Zone, *http.Response, error) { r := &dns.ZoneRecord{ Domain: "test.foo.com", ShortAns: []string{"2.2.2.2"}, TTL: 3600, Type: "A", ID: "123456789abcdefghijklmno", } z := &dns.Zone{ Zone: "foo.com", Records: []*dns.ZoneRecord{r}, TTL: 3600, ID: "12345678910111213141516a", } if zone == "foo.com" { return z, nil, nil } return nil, nil, nil } func (m *MockNS1DomainClient) ListZones() ([]*dns.Zone, *http.Response, error) { zones := []*dns.Zone{ {Zone: "foo.com", ID: "12345678910111213141516a"}, {Zone: "bar.com", ID: "12345678910111213141516b"}, } return zones, nil, nil } type MockNS1GetZoneFail struct{} func (m *MockNS1GetZoneFail) CreateRecord(_ *dns.Record) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1GetZoneFail) DeleteRecord(_ string, _ string, _ string) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1GetZoneFail) UpdateRecord(_ *dns.Record) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1GetZoneFail) GetZone(_ string) (*dns.Zone, *http.Response, error) { return nil, nil, api.ErrZoneMissing } func (m *MockNS1GetZoneFail) ListZones() ([]*dns.Zone, *http.Response, error) { zones := []*dns.Zone{ {Zone: "foo.com", ID: "12345678910111213141516a"}, {Zone: "bar.com", ID: "12345678910111213141516b"}, } return zones, nil, nil } type MockNS1ListZonesFail struct{} func (m *MockNS1ListZonesFail) CreateRecord(_ *dns.Record) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1ListZonesFail) DeleteRecord(_ string, _ string, _ string) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1ListZonesFail) UpdateRecord(_ *dns.Record) (*http.Response, error) { return &http.Response{}, nil } func (m *MockNS1ListZonesFail) GetZone(_ string) (*dns.Zone, *http.Response, error) { return &dns.Zone{}, &http.Response{}, nil } func (m *MockNS1ListZonesFail) ListZones() ([]*dns.Zone, *http.Response, error) { return nil, nil, fmt.Errorf("no zones available") } func TestNS1Records(t *testing.T) { provider := &NS1Provider{ client: &MockNS1DomainClient{}, domainFilter: endpoint.NewDomainFilter([]string{"foo.com."}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), minTTLSeconds: 3600, } ctx := t.Context() records, err := provider.Records(ctx) require.NoError(t, err) assert.Len(t, records, 1) provider.client = &MockNS1GetZoneFail{} _, err = provider.Records(ctx) require.Error(t, err) provider.client = &MockNS1ListZonesFail{} _, err = provider.Records(ctx) require.Error(t, err) } func TestNewNS1Provider(t *testing.T) { t.Setenv("NS1_APIKEY", "xxxxxxxxxxxxxxxxx") testNS1Config := NS1Config{ DomainFilter: endpoint.NewDomainFilter([]string{"foo.com."}), ZoneIDFilter: provider.NewZoneIDFilter([]string{""}), DryRun: false, } _, err := newProvider(testNS1Config) require.NoError(t, err) _ = os.Unsetenv("NS1_APIKEY") _, err = newProvider(testNS1Config) require.Error(t, err) } func TestNS1Zones(t *testing.T) { provider := &NS1Provider{ client: &MockNS1DomainClient{}, domainFilter: endpoint.NewDomainFilter([]string{"foo.com."}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), } zones, err := provider.zonesFiltered() require.NoError(t, err) validateNS1Zones(t, zones, []*dns.Zone{ {Zone: "foo.com"}, }) } func validateNS1Zones(t *testing.T, zones []*dns.Zone, expected []*dns.Zone) { require.Len(t, zones, len(expected)) for i, zone := range zones { assert.Equal(t, expected[i].Zone, zone.Zone) } } func TestNS1BuildRecord(t *testing.T) { change := &ns1Change{ Action: ns1Create, Endpoint: &endpoint.Endpoint{ DNSName: "new", Targets: endpoint.Targets{"target"}, RecordType: "A", }, } provider := &NS1Provider{ client: &MockNS1DomainClient{}, domainFilter: endpoint.NewDomainFilter([]string{"foo.com."}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), minTTLSeconds: 300, } record := provider.ns1BuildRecord("foo.com", change) assert.Equal(t, "foo.com", record.Zone) assert.Equal(t, "new.foo.com", record.Domain) assert.Equal(t, 300, record.TTL) changeWithTTL := &ns1Change{ Action: ns1Create, Endpoint: &endpoint.Endpoint{ DNSName: "new-b", Targets: endpoint.Targets{"target"}, RecordType: "A", RecordTTL: 3600, }, } record = provider.ns1BuildRecord("foo.com", changeWithTTL) assert.Equal(t, "foo.com", record.Zone) assert.Equal(t, "new-b.foo.com", record.Domain) assert.Equal(t, 3600, record.TTL) } func TestNS1ApplyChanges(t *testing.T) { changes := &plan.Changes{} provider := &NS1Provider{ client: &MockNS1DomainClient{}, } changes.Create = []*endpoint.Endpoint{ {DNSName: "new.foo.com", Targets: endpoint.Targets{"target"}}, {DNSName: "new.subdomain.bar.com", Targets: endpoint.Targets{"target"}}, } changes.Delete = []*endpoint.Endpoint{{DNSName: "test.foo.com", Targets: endpoint.Targets{"target"}}} changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test.foo.com", Targets: endpoint.Targets{"target-new"}}} err := provider.ApplyChanges(t.Context(), changes) require.NoError(t, err) // empty changes changes.Create = []*endpoint.Endpoint{} changes.Delete = []*endpoint.Endpoint{} changes.UpdateNew = []*endpoint.Endpoint{} err = provider.ApplyChanges(t.Context(), changes) require.NoError(t, err) } func TestNewNS1Changes(t *testing.T) { endpoints := []*endpoint.Endpoint{ { DNSName: "testa.foo.com", Targets: endpoint.Targets{"target-old"}, RecordType: "A", }, { DNSName: "testba.bar.com", Targets: endpoint.Targets{"target-new"}, RecordType: "A", }, } expected := []*ns1Change{ { Action: "ns1Create", Endpoint: endpoints[0], }, { Action: "ns1Create", Endpoint: endpoints[1], }, } changes := newNS1Changes("ns1Create", endpoints) require.Len(t, changes, len(expected)) assert.Equal(t, expected, changes) } func TestNewNS1ChangesByZone(t *testing.T) { provider := &NS1Provider{ client: &MockNS1DomainClient{}, } zones, _ := provider.zonesFiltered() changeSets := []*ns1Change{ { Action: "ns1Create", Endpoint: &endpoint.Endpoint{ DNSName: "new.foo.com", Targets: endpoint.Targets{"target"}, RecordType: "A", }, }, { Action: "ns1Create", Endpoint: &endpoint.Endpoint{ DNSName: "unrelated.bar.com", Targets: endpoint.Targets{"target"}, RecordType: "A", }, }, { Action: "ns1Delete", Endpoint: &endpoint.Endpoint{ DNSName: "test.foo.com", Targets: endpoint.Targets{"target"}, RecordType: "A", }, }, { Action: "ns1Update", Endpoint: &endpoint.Endpoint{ DNSName: "test.foo.com", Targets: endpoint.Targets{"target-new"}, RecordType: "A", }, }, } changes := ns1ChangesByZone(zones, changeSets) assert.Len(t, changes["bar.com"], 1) assert.Len(t, changes["foo.com"], 3) } ================================================ FILE: provider/oci/cache.go ================================================ /* Copyright 2023 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 oci import ( "time" "github.com/oracle/oci-go-sdk/v65/dns" ) type zoneCache struct { age time.Time duration time.Duration zones map[string]dns.ZoneSummary } func (z *zoneCache) Reset(zones map[string]dns.ZoneSummary) { if z.duration > time.Duration(0) { z.age = time.Now() z.zones = zones } } func (z *zoneCache) Get() map[string]dns.ZoneSummary { return z.zones } func (z *zoneCache) Expired() bool { return len(z.zones) < 1 || time.Since(z.age) > z.duration } ================================================ FILE: provider/oci/cache_test.go ================================================ /* Copyright 2023 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 oci import ( "testing" "time" "github.com/oracle/oci-go-sdk/v65/dns" "github.com/stretchr/testify/assert" ) func TestZoneCache(t *testing.T) { now := time.Now() var testCases = map[string]struct { z *zoneCache expired bool }{ "inactive-zone-cache": { &zoneCache{ duration: 0 * time.Second, }, true, }, "empty-active-zone-cache": { &zoneCache{ duration: 30 * time.Second, }, true, }, "expired-zone-cache": { &zoneCache{ age: now.Add(300 * time.Second), duration: 30 * time.Second, }, true, }, "active-zone-cache": { &zoneCache{ zones: map[string]dns.ZoneSummary{ zoneIdBaz: testPrivateZoneSummaryBaz, }, duration: 30 * time.Second, }, true, }, } for name, testCase := range testCases { t.Run(name, func(t *testing.T) { assert.Equal(t, testCase.expired, testCase.z.Expired()) var resetZoneLength = 1 if testCase.z.duration == 0 { resetZoneLength = 0 } testCase.z.Reset(map[string]dns.ZoneSummary{ zoneIdQux: testPrivateZoneSummaryQux, }) assert.Len(t, testCase.z.Get(), resetZoneLength) }) } } ================================================ FILE: provider/oci/oci.go ================================================ /* Copyright 2018 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 oci import ( "context" "errors" "fmt" "os" "strings" "time" "github.com/goccy/go-yaml" "github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/common/auth" "github.com/oracle/oci-go-sdk/v65/dns" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const defaultTTL = 300 // OCIAuthConfig holds connection parameters for the OCI API. type OCIAuthConfig struct { Region string `yaml:"region"` TenancyID string `yaml:"tenancy"` UserID string `yaml:"user"` PrivateKey string `yaml:"key"` Fingerprint string `yaml:"fingerprint"` Passphrase string `yaml:"passphrase"` UseInstancePrincipal bool `yaml:"useInstancePrincipal"` UseWorkloadIdentity bool `yaml:"useWorkloadIdentity"` } // OCIConfig holds the configuration for the OCI Provider. type OCIConfig struct { Auth OCIAuthConfig `yaml:"auth"` CompartmentID string `yaml:"compartment"` ZoneCacheDuration time.Duration } // OCIProvider is an implementation of Provider for Oracle Cloud Infrastructure // (OCI) DNS. type OCIProvider struct { provider.BaseProvider client ociDNSClient cfg OCIConfig domainFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter zoneScope string zoneCache *zoneCache dryRun bool } // ociDNSClient is the subset of the OCI DNS API required by the OCI Provider. type ociDNSClient interface { ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) } // New creates an OCI provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { var config *OCIConfig if cfg.OCIAuthInstancePrincipal { if len(cfg.OCICompartmentOCID) == 0 { return nil, fmt.Errorf("instance principal authentication requested, but no compartment OCID provided") } authConfig := OCIAuthConfig{UseInstancePrincipal: true} config = &OCIConfig{Auth: authConfig, CompartmentID: cfg.OCICompartmentOCID} } else { var err error if config, err = loadOCIConfig(cfg.OCIConfigFile); err != nil { return nil, err } } config.ZoneCacheDuration = cfg.OCIZoneCacheDuration return newProvider(*config, domainFilter, provider.NewZoneIDFilter(cfg.ZoneIDFilter), cfg.OCIZoneScope, cfg.DryRun) } // loadOCIConfig reads and parses the OCI ExternalDNS config file at the given path. func loadOCIConfig(path string) (*OCIConfig, error) { contents, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading OCI config file %q: %w", path, err) } cfg := OCIConfig{} if err := yaml.Unmarshal(contents, &cfg); err != nil { return nil, fmt.Errorf("parsing OCI config file %q: %w", path, err) } return &cfg, nil } // newProvider initializes a new OCI DNS based Provider. func newProvider(cfg OCIConfig, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneScope string, dryRun bool) (*OCIProvider, error) { var client ociDNSClient var err error var configProvider common.ConfigurationProvider if cfg.Auth.UseInstancePrincipal && cfg.Auth.UseWorkloadIdentity { return nil, errors.New("only one of 'useInstancePrincipal' and 'useWorkloadIdentity' may be enabled for Oracle authentication") } switch { case cfg.Auth.UseWorkloadIdentity: // OCI SDK requires specific, dynamic environment variables for workload identity. if err := os.Setenv(auth.ResourcePrincipalVersionEnvVar, auth.ResourcePrincipalVersion2_2); err != nil { return nil, fmt.Errorf("unable to set OCI SDK environment variable: %s: %w", auth.ResourcePrincipalVersionEnvVar, err) } if err := os.Setenv(auth.ResourcePrincipalRegionEnvVar, cfg.Auth.Region); err != nil { return nil, fmt.Errorf("unable to set OCI SDK environment variable: %s: %w", auth.ResourcePrincipalRegionEnvVar, err) } configProvider, err = auth.OkeWorkloadIdentityConfigurationProvider() if err != nil { return nil, fmt.Errorf("error creating OCI workload identity config provider: %w", err) } case cfg.Auth.UseInstancePrincipal: configProvider, err = auth.InstancePrincipalConfigurationProvider() if err != nil { return nil, fmt.Errorf("error creating OCI instance principal config provider: %w", err) } default: configProvider = common.NewRawConfigurationProvider( cfg.Auth.TenancyID, cfg.Auth.UserID, cfg.Auth.Region, cfg.Auth.Fingerprint, cfg.Auth.PrivateKey, &cfg.Auth.Passphrase, ) } client, err = dns.NewDnsClientWithConfigurationProvider(configProvider) if err != nil { return nil, fmt.Errorf("initializing OCI DNS API client: %w", err) } return &OCIProvider{ client: client, cfg: cfg, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, zoneScope: zoneScope, zoneCache: &zoneCache{ duration: cfg.ZoneCacheDuration, }, dryRun: dryRun, }, nil } func (p *OCIProvider) zones(ctx context.Context) (map[string]dns.ZoneSummary, error) { if !p.zoneCache.Expired() { log.Debug("Using cached zones list") return p.zoneCache.zones, nil } zones := make(map[string]dns.ZoneSummary) scopes := []dns.GetZoneScopeEnum{dns.GetZoneScopeEnum(p.zoneScope)} // If the zone scope is empty, list all zones types. if p.zoneScope == "" { scopes = dns.GetGetZoneScopeEnumValues() } log.Debugf("Matching zones against domain filters: %v", p.domainFilter.Filters) for _, scope := range scopes { if err := p.addPaginatedZones(ctx, zones, scope); err != nil { return nil, err } } if len(zones) == 0 { log.Warnf("No zones in compartment %q match domain filters %v", p.cfg.CompartmentID, p.domainFilter) } p.zoneCache.Reset(zones) return zones, nil } // Merge Endpoints with the same Name and Type into a single endpoint with multiple Targets. func mergeEndpointsMultiTargets(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint { endpointsByNameType := map[string][]*endpoint.Endpoint{} for _, ep := range endpoints { key := fmt.Sprintf("%s-%s", ep.DNSName, ep.RecordType) endpointsByNameType[key] = append(endpointsByNameType[key], ep) } // If there were no merges, return endpoints. if len(endpointsByNameType) == len(endpoints) { return endpoints } // Otherwise, create a new list of endpoints with the consolidated targets. var mergedEndpoints []*endpoint.Endpoint for _, ep := range endpointsByNameType { dnsName := ep[0].DNSName recordType := ep[0].RecordType recordTTL := ep[0].RecordTTL targets := make([]string, len(ep)) for i, e := range ep { targets[i] = e.Targets[0] } e := endpoint.NewEndpointWithTTL(dnsName, recordType, recordTTL, targets...) mergedEndpoints = append(mergedEndpoints, e) } return mergedEndpoints } func (p *OCIProvider) addPaginatedZones(ctx context.Context, zones map[string]dns.ZoneSummary, scope dns.GetZoneScopeEnum) error { var page *string // Loop until we have listed all zones. for { resp, err := p.client.ListZones(ctx, dns.ListZonesRequest{ CompartmentId: &p.cfg.CompartmentID, ZoneType: dns.ListZonesZoneTypePrimary, Scope: dns.ListZonesScopeEnum(scope), Page: page, }) if err != nil { return provider.NewSoftErrorf("listing zones in %s: %w", p.cfg.CompartmentID, err) } for _, zone := range resp.Items { if p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.Id) { zones[*zone.Id] = zone log.Debugf("Matched %q (%q)", *zone.Name, *zone.Id) } else { log.Debugf("Filtered %q (%q)", *zone.Name, *zone.Id) } } if page = resp.OpcNextPage; resp.OpcNextPage == nil { break } } return nil } func (p *OCIProvider) newFilteredRecordOperations(endpoints []*endpoint.Endpoint, opType dns.RecordOperationOperationEnum) []dns.RecordOperation { var ops []dns.RecordOperation for _, ep := range endpoints { if ep == nil { continue } if p.domainFilter.Match(ep.DNSName) { for _, t := range ep.Targets { singleTargetEp := &endpoint.Endpoint{ DNSName: ep.DNSName, Targets: []string{t}, RecordType: ep.RecordType, RecordTTL: ep.RecordTTL, Labels: ep.Labels, ProviderSpecific: ep.ProviderSpecific, } ops = append(ops, newRecordOperation(singleTargetEp, opType)) } } } return ops } // Records returns the list of records in a given hosted zone. func (p *OCIProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.zones(ctx) if err != nil { return nil, provider.NewSoftErrorf("getting zones: %w", err) } var endpoints []*endpoint.Endpoint for _, zone := range zones { var page *string for { resp, err := p.client.GetZoneRecords(ctx, dns.GetZoneRecordsRequest{ ZoneNameOrId: zone.Id, Page: page, CompartmentId: &p.cfg.CompartmentID, }) if err != nil { return nil, provider.NewSoftErrorf("getting records for zone %q: %w", *zone.Id, err) } for _, record := range resp.Items { if !provider.SupportedRecordType(*record.Rtype) { continue } endpoints = append(endpoints, endpoint.NewEndpointWithTTL( *record.Domain, *record.Rtype, endpoint.TTL(*record.Ttl), *record.Rdata, ), ) } if page = resp.OpcNextPage; resp.OpcNextPage == nil { break } } } endpoints = mergeEndpointsMultiTargets(endpoints) return endpoints, nil } // ApplyChanges applies a given set of changes to a given zone. func (p *OCIProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { log.Debugf("Processing changes: %+v", changes) var ops []dns.RecordOperation ops = append(ops, p.newFilteredRecordOperations(changes.Create, dns.RecordOperationOperationAdd)...) ops = append(ops, p.newFilteredRecordOperations(changes.UpdateNew, dns.RecordOperationOperationAdd)...) ops = append(ops, p.newFilteredRecordOperations(changes.UpdateOld, dns.RecordOperationOperationRemove)...) ops = append(ops, p.newFilteredRecordOperations(changes.Delete, dns.RecordOperationOperationRemove)...) if len(ops) == 0 { log.Info("All records are already up to date") return nil } zones, err := p.zones(ctx) if err != nil { return provider.NewSoftErrorf("fetching zones: %w", err) } // Separate into per-zone change sets to be passed to OCI API. opsByZone := operationsByZone(zones, ops) for zoneID, ops := range opsByZone { log.Infof("Change zone: %q", zoneID) for _, op := range ops { log.Info(op) } } if p.dryRun { return nil } for zoneID, ops := range opsByZone { if _, err := p.client.PatchZoneRecords(ctx, dns.PatchZoneRecordsRequest{ CompartmentId: &p.cfg.CompartmentID, ZoneNameOrId: &zoneID, PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{Items: ops}, }); err != nil { return provider.NewSoftError(err) } } return nil } // AdjustEndpoints modifies the endpoints as needed by the specific provider func (p *OCIProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { var adjustedEndpoints []*endpoint.Endpoint for _, e := range endpoints { // OCI DNS does not support the set-identifier attribute, so we remove it to avoid plan failure if e.SetIdentifier != "" { log.Warnf("Adjusting endpont: %v. Ignoring unsupported annotation 'set-identifier': %s", *e, e.SetIdentifier) e.SetIdentifier = "" } adjustedEndpoints = append(adjustedEndpoints, e) } return adjustedEndpoints, nil } // newRecordOperation returns a RecordOperation based on a given endpoint. func newRecordOperation(ep *endpoint.Endpoint, opType dns.RecordOperationOperationEnum) dns.RecordOperation { targets := make([]string, len(ep.Targets)) copy(targets, ep.Targets) if ep.RecordType == endpoint.RecordTypeCNAME { targets[0] = provider.EnsureTrailingDot(targets[0]) } rdata := strings.Join(targets, " ") ttl := defaultTTL if ep.RecordTTL.IsConfigured() { ttl = int(ep.RecordTTL) } return dns.RecordOperation{ Domain: &ep.DNSName, Rdata: &rdata, Ttl: &ttl, Rtype: &ep.RecordType, Operation: opType, } } // operationsByZone segments a slice of RecordOperations by their zone. func operationsByZone(zones map[string]dns.ZoneSummary, ops []dns.RecordOperation) map[string][]dns.RecordOperation { changes := make(map[string][]dns.RecordOperation) zoneNameIDMapper := provider.ZoneIDName{} for _, z := range zones { zoneNameIDMapper.Add(*z.Id, *z.Name) changes[*z.Id] = []dns.RecordOperation{} } for _, op := range ops { if zoneID, _ := zoneNameIDMapper.FindZone(*op.Domain); zoneID != "" { changes[zoneID] = append(changes[zoneID], op) } else { log.Warnf("No matching zone for record operation %s", op) } } // Remove zones that don't have any changes. for zone, ops := range changes { if len(ops) == 0 { delete(changes, zone) } } return changes } ================================================ FILE: provider/oci/oci_test.go ================================================ /* Copyright 2018 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 oci import ( "context" "errors" "fmt" "sort" "strings" "testing" "time" "github.com/oracle/oci-go-sdk/v65/common" "github.com/oracle/oci-go-sdk/v65/dns" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) type mockOCIDNSClient struct{} var ( zoneIdQux = "ocid1.dns-zone.oc1..123456ef0bfbb5c251b9713fd7bf8959" zoneNameQux = "qux.com" testPrivateZoneSummaryQux = dns.ZoneSummary{ Id: &zoneIdQux, Name: &zoneNameQux, } zoneIdBaz = "ocid1.dns-zone.oc1..789012ef0bfbb5c251b9713fd7bf8959" zoneNameBaz = "baz.com" testPrivateZoneSummaryBaz = dns.ZoneSummary{ Id: &zoneIdBaz, Name: &zoneNameBaz, } testGlobalZoneSummaryFoo = dns.ZoneSummary{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), } testGlobalZoneSummaryBar = dns.ZoneSummary{ Id: common.String("ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404"), Name: common.String("bar.com"), } ) func buildZoneResponseItems(scope dns.ListZonesScopeEnum, privateZones, globalZones []dns.ZoneSummary) []dns.ZoneSummary { switch string(scope) { case "PRIVATE": return privateZones case "GLOBAL": return globalZones default: return append(privateZones, globalZones...) } } func (c *mockOCIDNSClient) ListZones(_ context.Context, request dns.ListZonesRequest) (dns.ListZonesResponse, error) { if request.Page == nil || *request.Page == "0" { return dns.ListZonesResponse{ Items: buildZoneResponseItems(request.Scope, []dns.ZoneSummary{testPrivateZoneSummaryBaz}, []dns.ZoneSummary{testGlobalZoneSummaryFoo}), OpcNextPage: common.String("1"), }, nil } return dns.ListZonesResponse{ Items: buildZoneResponseItems(request.Scope, []dns.ZoneSummary{testPrivateZoneSummaryQux}, []dns.ZoneSummary{testGlobalZoneSummaryBar}), }, nil } func (c *mockOCIDNSClient) GetZoneRecords(_ context.Context, request dns.GetZoneRecordsRequest) (dns.GetZoneRecordsResponse, error) { var response dns.GetZoneRecordsResponse var err error if request.ZoneNameOrId == nil { return response, err } switch *request.ZoneNameOrId { case "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": if request.Page == nil || *request.Page == "0" { response.Items = []dns.Record{{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }, { Domain: common.String("foo.foo.com"), Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), Rtype: common.String(endpoint.RecordTypeTXT), Ttl: common.Int(defaultTTL), }} response.OpcNextPage = common.String("1") } else { response.Items = []dns.Record{{ Domain: common.String("bar.foo.com"), Rdata: common.String("bar.com."), Rtype: common.String(endpoint.RecordTypeCNAME), Ttl: common.Int(defaultTTL), }} } case "ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404": if request.Page == nil || *request.Page == "0" { response.Items = []dns.Record{{ Domain: common.String("foo.bar.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }} } } return response, err } func (c *mockOCIDNSClient) PatchZoneRecords(_ context.Context, _ dns.PatchZoneRecordsRequest) (dns.PatchZoneRecordsResponse, error) { return dns.PatchZoneRecordsResponse{}, nil } // newOCIProvider creates an OCI provider with API calls mocked out. func newOCIProvider(client ociDNSClient, domainFilter *endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, zoneScope string, dryRun bool) *OCIProvider { return &OCIProvider{ client: client, cfg: OCIConfig{ CompartmentID: "ocid1.compartment.oc1..aaaaaaaaujjg4lf3v6uaqeml7xfk7stzvrxeweaeyolhh75exuoqxpqjb4qq", }, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, zoneScope: zoneScope, zoneCache: &zoneCache{ duration: 0 * time.Second, }, dryRun: dryRun, } } func validateOCIZones(t *testing.T, actual, expected map[string]dns.ZoneSummary) { require.Len(t, actual, len(expected)) for k, a := range actual { e, ok := expected[k] require.True(t, ok, "unexpected zone %q (%q)", *a.Name, *a.Id) require.Equal(t, e, a) } } func TestNewOCIProvider(t *testing.T) { testCases := map[string]struct { config OCIConfig err error }{ "valid": { config: OCIConfig{ Auth: OCIAuthConfig{ TenancyID: "ocid1.tenancy.oc1..aaaaaaaaxf3fuazosc6xng7l75rj6uist5jb6ken64t3qltimxnkymddqbma", UserID: "ocid1.user.oc1..aaaaaaaahx2vpvm4of5nqq3t274ike7ygyk2aexvokk3gyv4eyumzqajcrvq", Region: "us-ashburn-1", Fingerprint: "48:ba:d4:21:63:53:db:10:65:20:d4:09:ce:01:f5:97", PrivateKey: `-----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAv2JspZyO14kqcO/X4iz3ZdcyAf1GQJqYsBb6wyrlU0PB9Fee H23/HLtMSqeqo+2KQHmdV1OHFQ/S6tx7zcBaby/+2b+z3/gJO4PGxohe2812AJ/J W8Fp/4EnwbaRqDhoLN7ms0/e566zE3z40kCSW0NAIzv/F+0nNaka1xrypBqzvaNm N49dAGvqWRpzFFUb8CbvKmgE6c/H4a2zVNW3G7/K6Og4HQGeEP3NKSVvi0BiQlvd tVJTg7084kKcrngsS2N3qI3pzsr5wgpzPPefuPHWRKokZ20kpu8tXdFt+mAC2NHh eWbtY3jsR6JFaXCyZLMXInwDvRgdP0T5+uh8WwIDAQABAoIBAG0rr94omDLKw7L4 naUfEWC+iIAqAdEIXuDTuudpqLb+h7zh3gj/re6tyK8tRWGNNrfgp6gQtZWGGUJv 0w9jEjMqpa2AdRLlYh7Y5KKLV9D6Or3QaAQ3KEffXNZbVmsnAgXWgLL4dKakOPJ8 71LAEryMeCGhL7puRVeOxwi9Dnwc4pcloimdggw/uwVHMK9eY5ylyt5ziiiWfhAo cnNJNPHRSTqSiCoEhk/8BLZT5gxf1YX0hVSEdQh2WNyxmPmVSC9uuzKOqcEBfHf5 hmLnsUET1REM9IxCLqC9ebW263lIO/KdGiCu+YgIdwIi3wrLhaKXAZQmp4oMvWlE n5eYlcECgYEA5AhctPWCQBCJhcD39pSWgnSq1O9bt8yQi2P2stqlxKV9ZBepCK49 OT42OYPUgWn7/y//6/LLzsPY58VTDHF3xZN1qu+fU0IM22D3Jqc19pnfVEb6TXSc 0jJIiaYCWTdqRQ4p2DuDcI+EzRB+V1Z7tFWxshZWXwNvtMXNoYPOYaUCgYEA1ttn R3pCuGYJ5XbBwPzD5J+hvdZ6TQf8oTDraUBPxjtFOr7ea42T6KeYRFvnK2AQDnKL Mw3I55lNO4I2W9gahUFG28dhxEuxeyvXGqXEJvPCUYePstab/BkUrm7/jkS3CLcJ dlRXjqOfGwi5+NPUZMoOkZ54ZR4ZpdhIAeEpBf8CgYEAyMyMRlVCowNs9jkcoSfq +Wme3O8BhvI9/mDCZnCfNHC94Bvtn1U/WF7uBOuPf35Ch05PQAiHa8WOBVn/bZ+l ZngZT7K+S+SHyc6zFHh9zm9k96Og2f/r8DSTJ5Ll0oY3sCNuuZh+f+oBeUoi1umy +PPVDAsbd4NhJIBiOO4GGHkCgYA1p4i9Es0Cm4ixItzzwqtwtmR/scXM4se1wS+o kwTY7gg1yWBl328mVGPz/jdWX6Di2rvkPfcDzwa4a6YDfY3x5QE69Sl3CagCqEoJ P4giahEGpyG9eVZuuBywCswKzSIgLQVR5XIQDtA2whEfEFcj7EmDF93c8o1ZGw+w WHgUJQKBgEXr0HgxGG+v8bsXdrJ87Avx/nuA2rrFfECDPa4zuPkEK+cSFibdAq/H u6OIV+z59AD2s84gxR+KLzEDfQAqBt7cVA5ZH6hrO+bkCtK9ycLL+koOuB+1EV+Y hKRtDhmSdWBo3tJK12RrAe4t7CUe8gMgTvU7ExlcA3xQkseFPx9K -----END RSA PRIVATE KEY----- `, }, }, }, "invalid": { config: OCIConfig{ Auth: OCIAuthConfig{ TenancyID: "ocid1.tenancy.oc1..aaaaaaaaxf3fuazosc6xng7l75rj6uist5jb6ken64t3qltimxnkymddqbma", UserID: "ocid1.user.oc1..aaaaaaaahx2vpvm4of5nqq3t274ike7ygyk2aexvokk3gyv4eyumzqajcrvq", Region: "us-ashburn-1", Fingerprint: "48:ba:d4:21:63:53:db:10:65:20:d4:09:ce:01:f5:97", PrivateKey: `-----BEGIN RSA PRIVATE KEY----- `, }, }, err: errors.New("initializing OCI DNS API client: can not create client, bad configuration: PEM data was not found in buffer"), }, "invalid-auth-methods": { config: OCIConfig{ Auth: OCIAuthConfig{ Region: "us-ashburn-1", UseInstancePrincipal: true, UseWorkloadIdentity: true, }, }, err: errors.New("only one of 'useInstancePrincipal' and 'useWorkloadIdentity' may be enabled for Oracle authentication"), }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { _, err := newProvider( tc.config, endpoint.NewDomainFilter([]string{"com"}), provider.NewZoneIDFilter([]string{""}), string(dns.GetZoneScopeGlobal), false, ) if err == nil { require.NoError(t, err) } else { // have to use prefix testing because the expected instance-principal error strings vary after a known prefix require.Truef(t, strings.HasPrefix(err.Error(), tc.err.Error()), "observed: %s", err.Error()) } }) } } func TestOCIZones(t *testing.T) { fooZoneId := "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959" barZoneId := "ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404" testCases := []struct { name string domainFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter zoneScope string expected map[string]dns.ZoneSummary }{ { name: "AllZones", domainFilter: endpoint.NewDomainFilter([]string{"com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), zoneScope: "", expected: map[string]dns.ZoneSummary{ fooZoneId: testGlobalZoneSummaryFoo, barZoneId: testGlobalZoneSummaryBar, zoneIdBaz: testPrivateZoneSummaryBaz, zoneIdQux: testPrivateZoneSummaryQux, }, }, { name: "Privatezones", domainFilter: endpoint.NewDomainFilter([]string{"com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), zoneScope: "PRIVATE", expected: map[string]dns.ZoneSummary{ zoneIdBaz: testPrivateZoneSummaryBaz, zoneIdQux: testPrivateZoneSummaryQux, }, }, { name: "DomainFilter_com", domainFilter: endpoint.NewDomainFilter([]string{"com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), zoneScope: "GLOBAL", expected: map[string]dns.ZoneSummary{ fooZoneId: testGlobalZoneSummaryFoo, barZoneId: testGlobalZoneSummaryBar, }, }, { name: "DomainFilter_foo.com", domainFilter: endpoint.NewDomainFilter([]string{"foo.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), zoneScope: "GLOBAL", expected: map[string]dns.ZoneSummary{ fooZoneId: { Id: common.String(fooZoneId), Name: common.String("foo.com"), }, }, }, { name: "ZoneIDFilter_ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959", domainFilter: endpoint.NewDomainFilter([]string{""}), zoneIDFilter: provider.NewZoneIDFilter([]string{"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"}), zoneScope: "GLOBAL", expected: map[string]dns.ZoneSummary{ fooZoneId: { Id: common.String(fooZoneId), Name: common.String("foo.com"), }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { provider := newOCIProvider(&mockOCIDNSClient{}, tc.domainFilter, tc.zoneIDFilter, tc.zoneScope, false) zones, err := provider.zones(t.Context()) require.NoError(t, err) validateOCIZones(t, zones, tc.expected) }) } } func TestOCIRecords(t *testing.T) { testCases := []struct { name string domainFilter *endpoint.DomainFilter zoneIDFilter provider.ZoneIDFilter expected []*endpoint.Endpoint }{ { name: "unfiltered", domainFilter: endpoint.NewDomainFilter([]string{""}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1"), endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), endpoint.NewEndpointWithTTL("bar.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "bar.com."), endpoint.NewEndpointWithTTL("foo.bar.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1"), }, }, { name: "DomainFilter_foo.com", domainFilter: endpoint.NewDomainFilter([]string{"foo.com"}), zoneIDFilter: provider.NewZoneIDFilter([]string{""}), expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1"), endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), endpoint.NewEndpointWithTTL("bar.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "bar.com."), }, }, { name: "ZoneIDFilter_ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404", domainFilter: endpoint.NewDomainFilter([]string{""}), zoneIDFilter: provider.NewZoneIDFilter([]string{"ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404"}), expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("foo.bar.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1"), }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { provider := newOCIProvider(&mockOCIDNSClient{}, tc.domainFilter, tc.zoneIDFilter, "", false) endpoints, err := provider.Records(t.Context()) require.NoError(t, err) require.ElementsMatch(t, tc.expected, endpoints) }) } } func TestNewRecordOperation(t *testing.T) { testCases := []struct { name string ep *endpoint.Endpoint opType dns.RecordOperationOperationEnum expected dns.RecordOperation }{ { name: "A_record", opType: dns.RecordOperationOperationAdd, ep: endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1"), expected: dns.RecordOperation{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, }, { name: "TXT_record", opType: dns.RecordOperationOperationAdd, ep: endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), expected: dns.RecordOperation{ Domain: common.String("foo.foo.com"), Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), Rtype: common.String("TXT"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, }, { name: "CNAME_record", opType: dns.RecordOperationOperationAdd, ep: endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "bar.com."), expected: dns.RecordOperation{ Domain: common.String("foo.foo.com"), Rdata: common.String("bar.com."), Rtype: common.String("CNAME"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { op := newRecordOperation(tc.ep, tc.opType) require.Equal(t, tc.expected, op) }) } } func TestOperationsByZone(t *testing.T) { testCases := []struct { name string zones map[string]dns.ZoneSummary ops []dns.RecordOperation expected map[string][]dns.RecordOperation }{ { name: "basic", zones: map[string]dns.ZoneSummary{ "foo": { Id: common.String("foo"), Name: common.String("foo.com"), }, "bar": { Id: common.String("bar"), Name: common.String("bar.com"), }, }, ops: []dns.RecordOperation{ { Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, { Domain: common.String("foo.bar.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, }, expected: map[string][]dns.RecordOperation{ "foo": { { Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, }, "bar": { { Domain: common.String("foo.bar.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, }, }, }, { name: "does_not_include_zones_with_no_changes", zones: map[string]dns.ZoneSummary{ "foo": { Id: common.String("foo"), Name: common.String("foo.com"), }, "bar": { Id: common.String("bar"), Name: common.String("bar.com"), }, }, ops: []dns.RecordOperation{ { Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, }, expected: map[string][]dns.RecordOperation{ "foo": { { Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }, }, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := operationsByZone(tc.zones, tc.ops) require.Equal(t, tc.expected, result) }) } } type mutableMockOCIDNSClient struct { zones map[string]dns.ZoneSummary records map[string]map[string]dns.Record } func newMutableMockOCIDNSClient(zones []dns.ZoneSummary, recordsByZone map[string][]dns.Record) *mutableMockOCIDNSClient { c := &mutableMockOCIDNSClient{ zones: make(map[string]dns.ZoneSummary), records: make(map[string]map[string]dns.Record), } for _, zone := range zones { c.zones[*zone.Id] = zone c.records[*zone.Id] = make(map[string]dns.Record) } for zoneID, records := range recordsByZone { for _, record := range records { c.records[zoneID][ociRecordKey(*record.Rtype, *record.Domain, *record.Rdata)] = record } } return c } func (c *mutableMockOCIDNSClient) ListZones(_ context.Context, _ dns.ListZonesRequest) (dns.ListZonesResponse, error) { var zones []dns.ZoneSummary for _, v := range c.zones { zones = append(zones, v) } return dns.ListZonesResponse{Items: zones}, nil } func (c *mutableMockOCIDNSClient) GetZoneRecords(_ context.Context, request dns.GetZoneRecordsRequest) (dns.GetZoneRecordsResponse, error) { var response dns.GetZoneRecordsResponse if request.ZoneNameOrId == nil { return response, errors.New("no name or id") } records, ok := c.records[*request.ZoneNameOrId] if !ok { return response, errors.New("zone not found") } var items []dns.Record for _, v := range records { items = append(items, v) } response.Items = items return response, nil } func ociRecordKey(rType, domain string, ip string) string { rdata := "" if rType == "A" { // adds support for multi-targets with same rtype and domain rdata = "_" + ip } return rType + "_" + domain + rdata } func sortEndpointTargets(endpoints []*endpoint.Endpoint) { for _, ep := range endpoints { sort.Strings([]string(ep.Targets)) } } func (c *mutableMockOCIDNSClient) PatchZoneRecords(_ context.Context, request dns.PatchZoneRecordsRequest) (dns.PatchZoneRecordsResponse, error) { var response dns.PatchZoneRecordsResponse if request.ZoneNameOrId == nil { return response, errors.New("no name or id") } records, ok := c.records[*request.ZoneNameOrId] if !ok { return response, errors.New("zone not found") } // Ensure that ADD operations occur after REMOVE. sort.Slice(request.Items, func(i, j int) bool { return request.Items[i].Operation > request.Items[j].Operation }) for _, op := range request.Items { k := ociRecordKey(*op.Rtype, *op.Domain, *op.Rdata) switch op.Operation { case dns.RecordOperationOperationAdd: records[k] = dns.Record{ Domain: op.Domain, Rtype: op.Rtype, Rdata: op.Rdata, Ttl: op.Ttl, } case dns.RecordOperationOperationRemove: delete(records, k) default: return response, fmt.Errorf("unsupported operation %q", op.Operation) } } return response, nil } // TestMutableMockOCIDNSClient exists because one must always test one's tests // right...? func TestMutableMockOCIDNSClient(t *testing.T) { zones := []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }} records := map[string][]dns.Record{ "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }, { Domain: common.String("foo.foo.com"), Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), Rtype: common.String(endpoint.RecordTypeTXT), Ttl: common.Int(defaultTTL), }}, } client := newMutableMockOCIDNSClient(zones, records) // First ListZones. zonesResponse, err := client.ListZones(t.Context(), dns.ListZonesRequest{}) require.NoError(t, err) require.Len(t, zonesResponse.Items, 1) require.Equal(t, zonesResponse.Items, zones) // GetZoneRecords for that zone. recordsResponse, err := client.GetZoneRecords(t.Context(), dns.GetZoneRecordsRequest{ ZoneNameOrId: zones[0].Id, }) require.NoError(t, err) require.Len(t, recordsResponse.Items, 2) require.ElementsMatch(t, recordsResponse.Items, records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"]) // Remove the A record. _, err = client.PatchZoneRecords(t.Context(), dns.PatchZoneRecordsRequest{ ZoneNameOrId: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{ Items: []dns.RecordOperation{{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationRemove, }}, }, }) require.NoError(t, err) // GetZoneRecords again and check the A record was removed. recordsResponse, err = client.GetZoneRecords(t.Context(), dns.GetZoneRecordsRequest{ ZoneNameOrId: zones[0].Id, }) require.NoError(t, err) require.Len(t, recordsResponse.Items, 1) require.Equal(t, recordsResponse.Items[0], records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"][1]) // Add the A record back. _, err = client.PatchZoneRecords(t.Context(), dns.PatchZoneRecordsRequest{ ZoneNameOrId: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{ Items: []dns.RecordOperation{{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String("A"), Ttl: common.Int(300), Operation: dns.RecordOperationOperationAdd, }}, }, }) require.NoError(t, err) // GetZoneRecords and check we're back in the original state recordsResponse, err = client.GetZoneRecords(t.Context(), dns.GetZoneRecordsRequest{ ZoneNameOrId: zones[0].Id, }) require.NoError(t, err) require.Len(t, recordsResponse.Items, 2) require.ElementsMatch(t, recordsResponse.Items, records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"]) } func TestOCIApplyChanges(t *testing.T) { testCases := []struct { name string zones []dns.ZoneSummary records map[string][]dns.Record changes *plan.Changes dryRun bool err error expectedEndpoints []*endpoint.Endpoint }{ { name: "add", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, changes: &plan.Changes{ Create: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1", )}, }, expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1", )}, }, { name: "remove", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, records: map[string][]dns.Record{ "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }, { Domain: common.String("foo.foo.com"), Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), Rtype: common.String(endpoint.RecordTypeTXT), Ttl: common.Int(defaultTTL), }}, }, changes: &plan.Changes{ Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeTXT, endpoint.TTL(defaultTTL), "127.0.0.1", )}, }, expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1", )}, }, { name: "update", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, records: map[string][]dns.Record{ "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }}, }, changes: &plan.Changes{ UpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1", )}, UpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "10.0.0.1", )}, }, expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "10.0.0.1", )}, }, { name: "dry_run_no_changes", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, records: map[string][]dns.Record{ "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }}, }, changes: &plan.Changes{ Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1", )}, }, dryRun: true, expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1", )}, }, { name: "add_remove_update", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, records: map[string][]dns.Record{ "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ Domain: common.String("foo.foo.com"), Rdata: common.String("127.0.0.1"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }, { Domain: common.String("car.foo.com"), Rdata: common.String("bar.com."), Rtype: common.String(endpoint.RecordTypeCNAME), Ttl: common.Int(defaultTTL), }, { Domain: common.String("bar.foo.com"), Rdata: common.String("baz.com."), Rtype: common.String(endpoint.RecordTypeCNAME), Ttl: common.Int(defaultTTL), }}, }, changes: &plan.Changes{ Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1", )}, UpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "car.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "baz.com.", )}, UpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "bar.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "foo.bar.com.", )}, Create: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "baz.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1", )}, }, expectedEndpoints: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL( "bar.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(defaultTTL), "foo.bar.com.", ), endpoint.NewEndpointWithTTL( "baz.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "127.0.0.1"), }, }, { name: "combine_multi_target", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, changes: &plan.Changes{ Create: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "192.168.1.2", ), endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "192.168.2.5", )}, }, expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "192.168.1.2", "192.168.2.5", )}, }, { name: "remove_from_multi_target", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, records: map[string][]dns.Record{ "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ Domain: common.String("foo.foo.com"), Rdata: common.String("192.168.1.2"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }, { Domain: common.String("foo.foo.com"), Rdata: common.String("192.168.2.5"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }}, }, changes: &plan.Changes{ Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "192.168.1.2", )}, }, expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "192.168.2.5", )}, }, { name: "update_multi_target", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, records: map[string][]dns.Record{ "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ Domain: common.String("first.foo.com"), Rdata: common.String("10.77.4.5"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }}, }, changes: &plan.Changes{ UpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "first.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "10.77.4.5", )}, UpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "first.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "10.77.6.10", )}, }, expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "first.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "10.77.6.10", )}, }, { name: "increase_multi_target", zones: []dns.ZoneSummary{{ Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), Name: common.String("foo.com"), }}, records: map[string][]dns.Record{ "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ Domain: common.String("first.foo.com"), Rdata: common.String("10.77.4.5"), Rtype: common.String(endpoint.RecordTypeA), Ttl: common.Int(defaultTTL), }}, }, changes: &plan.Changes{ Create: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "first.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "10.77.6.10", )}, }, expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( "first.foo.com", endpoint.RecordTypeA, endpoint.TTL(defaultTTL), "10.77.4.5", "10.77.6.10", )}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { client := newMutableMockOCIDNSClient(tc.zones, tc.records) provider := newOCIProvider( client, endpoint.NewDomainFilter([]string{""}), provider.NewZoneIDFilter([]string{""}), "", tc.dryRun, ) ctx := t.Context() err := provider.ApplyChanges(ctx, tc.changes) require.Equal(t, tc.err, err) endpoints, err := provider.Records(ctx) require.NoError(t, err) sortEndpointTargets(endpoints) sortEndpointTargets(tc.expectedEndpoints) require.ElementsMatch(t, tc.expectedEndpoints, endpoints) }) } } ================================================ FILE: provider/ovh/ovh.go ================================================ /* Copyright 2020 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 ovh import ( "context" "errors" "fmt" "net/url" "slices" "strconv" "strings" "time" "github.com/miekg/dns" "github.com/ovh/go-ovh/ovh" "github.com/patrickmn/go-cache" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/idna" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "go.uber.org/ratelimit" ) const ( defaultTTL = 0 ovhCreate = iota ovhDelete ovhUpdate ) var ( // ErrRecordToMutateNotFound when ApplyChange has to update/delete and didn't found the record in the existing zone (Change with no record ID) ErrRecordToMutateNotFound = errors.New("record to mutate not found in current zone") ) // OVHProvider is an implementation of Provider for OVH DNS. type OVHProvider struct { provider.BaseProvider client ovhClient apiRateLimiter ratelimit.Limiter domainFilter *endpoint.DomainFilter // DryRun enables dry-run mode DryRun bool // EnableCNAMERelativeTarget controls if CNAME target should be sent with relative format. // Previous implementations of the OVHProvider always added a final dot as for absolut format. // Default value is false, all CNAME are transformed into absolut format. // Setting this to true will allow relative format to be sent to DNS zone. EnableCNAMERelativeTarget bool // UseCache controls if the OVHProvider will cache records in memory, and serve them // without recontacting the OVHcloud API if the SOA of the domain zone hasn't changed. // Note that, when disabling cache, OVHcloud API has rate-limiting that will hit if // your refresh rate/number of records is too big, which might cause issue with the // provider. // Default value: true UseCache bool lastRunRecords []ovhRecord lastRunZones []string cacheInstance *cache.Cache dnsClient dnsClient } type ovhClient interface { PostWithContext(context.Context, string, any, any) error PutWithContext(context.Context, string, any, any) error GetWithContext(context.Context, string, any) error DeleteWithContext(context.Context, string, any) error } type dnsClient interface { ExchangeContext(ctx context.Context, m *dns.Msg, a string) (*dns.Msg, time.Duration, error) } type ovhRecordFields struct { ovhRecordFieldUpdate FieldType string `json:"fieldType"` } type ovhRecordFieldUpdate struct { SubDomain string `json:"subDomain"` TTL int64 `json:"ttl"` Target string `json:"target"` } type ovhRecord struct { ovhRecordFields ID uint64 `json:"id"` Zone string `json:"zone"` } func (r ovhRecord) String() string { return "record#" + strconv.Itoa(int(r.ID)) + ": " + r.FieldType + " | " + r.SubDomain + " => " + r.Target + " (" + strconv.Itoa(int(r.TTL)) + ")" } type ovhChange struct { ovhRecord Action int } // New creates an OVH provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(domainFilter, cfg.OVHEndpoint, cfg.OVHApiRateLimit, cfg.OVHEnableCNAMERelative, cfg.DryRun) } // newProvider initializes a new OVH DNS based Provider. func newProvider( domainFilter *endpoint.DomainFilter, endpoint string, apiRateLimit int, enableCNAMERelative, dryRun bool) (*OVHProvider, error) { client, err := ovh.NewEndpointClient(endpoint) if err != nil { return nil, err } client.UserAgent = externaldns.UserAgent() return &OVHProvider{ client: client, domainFilter: domainFilter, apiRateLimiter: ratelimit.New(apiRateLimit), DryRun: dryRun, cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), dnsClient: new(dns.Client), UseCache: true, EnableCNAMERelativeTarget: enableCNAMERelative, }, nil } // Records returns the list of records in all relevant zones. func (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, records, err := p.zonesRecords(ctx) if err != nil { return nil, err } p.lastRunRecords = records p.lastRunZones = zones endpoints := ovhGroupByNameAndType(records) log.Infof("OVH: %d endpoints have been found", len(endpoints)) return endpoints, nil } func planChangesByZoneName(zones []string, changes *plan.Changes) (map[string]*plan.Changes, error) { zoneNameIDMapper := provider.ZoneIDName{} for _, zone := range zones { zoneNameIDMapper.Add(zone, zone) } output := map[string]*plan.Changes{} for _, endpt := range changes.Delete { zoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName) if zoneName == "" { return nil, provider.NewSoftErrorf("record %q have not found matching DNS zone in OVH provider", endpt.DNSName) } if _, ok := output[zoneName]; !ok { output[zoneName] = &plan.Changes{} } output[zoneName].Delete = append(output[zoneName].Delete, endpt) } for _, endpt := range changes.Create { zoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName) if zoneName == "" { return nil, provider.NewSoftErrorf("record %q have not found matching DNS zone in OVH provider", endpt.DNSName) } if _, ok := output[zoneName]; !ok { output[zoneName] = &plan.Changes{} } output[zoneName].Create = append(output[zoneName].Create, endpt) } for _, endpt := range changes.UpdateOld { zoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName) if zoneName == "" { return nil, provider.NewSoftErrorf("record %q have not found matching DNS zone in OVH provider", endpt.DNSName) } if _, ok := output[zoneName]; !ok { output[zoneName] = &plan.Changes{} } output[zoneName].UpdateOld = append(output[zoneName].UpdateOld, endpt) } for _, endpt := range changes.UpdateNew { zoneName, _ := zoneNameIDMapper.FindZone(endpt.DNSName) if zoneName == "" { return nil, provider.NewSoftErrorf("record %q have not found matching DNS zone in OVH provider", endpt.DNSName) } if _, ok := output[zoneName]; !ok { output[zoneName] = &plan.Changes{} } output[zoneName].UpdateNew = append(output[zoneName].UpdateNew, endpt) } return output, nil } func (p *OVHProvider) computeSingleZoneChanges(_ context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) ([]ovhChange, error) { allChanges := []ovhChange{} var computedChanges []ovhChange computedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhCreate, changes.Create, zoneName, existingRecords) allChanges = append(allChanges, computedChanges...) computedChanges, existingRecords = p.newOvhChangeCreateDelete(ovhDelete, changes.Delete, zoneName, existingRecords) allChanges = append(allChanges, computedChanges...) var err error computedChanges, err = p.newOvhChangeUpdate(changes.UpdateOld, changes.UpdateNew, zoneName, existingRecords) if err != nil { return nil, err } allChanges = append(allChanges, computedChanges...) return allChanges, nil } func (p *OVHProvider) handleSingleZoneUpdate(ctx context.Context, zoneName string, existingRecords []ovhRecord, changes *plan.Changes) error { allChanges, err := p.computeSingleZoneChanges(ctx, zoneName, existingRecords, changes) if err != nil { return err } log.Infof("OVH: %q: %d changes will be done", zoneName, len(allChanges)) eg, ctxErrGroup := errgroup.WithContext(ctx) for _, change := range allChanges { eg.Go(func() error { return p.change(ctxErrGroup, change) }) } err = eg.Wait() // do not refresh zone if errors: some records might haven't been processed yet, hence the zone will be in an inconsistent state // if modification of the zone was in error, invalidating the cache to make sure next run will start freshly if err == nil { err = p.refresh(ctx, zoneName) } else { p.invalidateCache(zoneName) } return err } // ApplyChanges applies a given set of changes in a given zone. func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { zones, records := p.lastRunZones, p.lastRunRecords defer func() { p.lastRunRecords = []ovhRecord{} p.lastRunZones = []string{} }() if log.IsLevelEnabled(log.DebugLevel) { for _, change := range changes.Create { log.Debugf("OVH: changes CREATE dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType) } for _, change := range changes.UpdateOld { log.Debugf("OVH: changes UPDATEOLD dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType) } for _, change := range changes.UpdateNew { log.Debugf("OVH: changes UPDATENEW dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType) } for _, change := range changes.Delete { log.Debugf("OVH: changes DELETE dns:%q / targets:%v / type:%s", change.DNSName, change.Targets, change.RecordType) } } changesByZoneName, err := planChangesByZoneName(zones, changes) if err != nil { return err } eg, ctx := errgroup.WithContext(ctx) for zoneName, changes := range changesByZoneName { eg.Go(func() error { return p.handleSingleZoneUpdate(ctx, zoneName, records, changes) }) } if err := eg.Wait(); err != nil { return provider.NewSoftError(err) } return nil } func (p *OVHProvider) refresh(ctx context.Context, zone string) error { log.Debugf("OVH: Refresh %s zone", zone) // Zone has been altered so we invalidate the cache // so that the next run will reload it. p.invalidateCache(zone) p.apiRateLimiter.Take() if p.DryRun { log.Infof("OVH: Dry-run: Would have refresh DNS zone %q", zone) return nil } if err := p.client.PostWithContext(ctx, fmt.Sprintf("/domain/zone/%s/refresh", url.PathEscape(zone)), nil, nil); err != nil { return provider.NewSoftError(err) } return nil } func (p *OVHProvider) change(ctx context.Context, change ovhChange) error { p.apiRateLimiter.Take() switch change.Action { case ovhCreate: log.Debugf("OVH: Add an entry to %s", change.String()) if p.DryRun { log.Infof("OVH: Dry-run: Would have created a DNS record for zone %s", change.Zone) return nil } return p.client.PostWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record", url.PathEscape(change.Zone)), change.ovhRecordFields, nil) case ovhDelete: if change.ID == 0 { return ErrRecordToMutateNotFound } log.Debugf("OVH: Delete an entry to %s", change.String()) if p.DryRun { log.Infof("OVH: Dry-run: Would have deleted a DNS record for zone %s", change.Zone) return nil } return p.client.DeleteWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(change.Zone), change.ID), nil) case ovhUpdate: if change.ID == 0 { return ErrRecordToMutateNotFound } log.Debugf("OVH: Update an entry to %s", change.String()) if p.DryRun { log.Infof("OVH: Dry-run: Would have updated a DNS record for zone %s", change.Zone) return nil } return p.client.PutWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(change.Zone), change.ID), change.ovhRecordFieldUpdate, nil) default: return nil } } func (p *OVHProvider) invalidateCache(zone string) { p.cacheInstance.Delete(zone + "#soa") } func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) { var allRecords []ovhRecord zones, err := p.zones(ctx) if err != nil { return nil, nil, provider.NewSoftError(err) } chRecords := make(chan []ovhRecord, len(zones)) eg, ctx := errgroup.WithContext(ctx) for _, zone := range zones { eg.Go(func() error { return p.records(ctx, &zone, chRecords) }) } if err := eg.Wait(); err != nil { return nil, nil, provider.NewSoftError(err) } close(chRecords) for records := range chRecords { allRecords = append(allRecords, records...) } return zones, allRecords, nil } func (p *OVHProvider) zones(ctx context.Context) ([]string, error) { var zones []string var filteredZones []string p.apiRateLimiter.Take() if err := p.client.GetWithContext(ctx, "/domain/zone", &zones); err != nil { return nil, err } for _, zoneName := range zones { if p.domainFilter == nil || p.domainFilter.Match(zoneName) { filteredZones = append(filteredZones, zoneName) } } log.Infof("OVH: %d zones found", len(filteredZones)) return filteredZones, nil } type ovhSoa struct { Server string `json:"server"` Serial uint32 `json:"serial"` records []ovhRecord } func (p *OVHProvider) records(ctx context.Context, zone *string, records chan<- []ovhRecord) error { var recordsIds []uint64 ovhRecords := make([]ovhRecord, len(recordsIds)) eg, ctxErrGroup := errgroup.WithContext(ctx) if p.UseCache { if cachedSoaItf, ok := p.cacheInstance.Get(*zone + "#soa"); ok { cachedSoa := cachedSoaItf.(ovhSoa) log.Debugf("OVH: zone %s: Checking SOA against %v", *zone, cachedSoa.Serial) m := new(dns.Msg) m.SetQuestion(dns.Fqdn(*zone), dns.TypeSOA) in, _, err := p.dnsClient.ExchangeContext(ctx, m, strings.TrimSuffix(cachedSoa.Server, ".")+":53") if err == nil { if s, ok := in.Answer[0].(*dns.SOA); ok { if s.Serial == cachedSoa.Serial { log.Debugf("OVH: zone %s: SOA from cache is valid", *zone) records <- cachedSoa.records return nil } } } p.invalidateCache(*zone) } } log.Debugf("OVH: Getting records for %s from API", *zone) p.apiRateLimiter.Take() var soa ovhSoa if p.UseCache { if err := p.client.GetWithContext(ctx, "/domain/zone/"+url.PathEscape(*zone)+"/soa", &soa); err != nil { return err } } if err := p.client.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record", url.PathEscape(*zone)), &recordsIds); err != nil { return err } chRecords := make(chan ovhRecord, len(recordsIds)) for _, id := range recordsIds { eg.Go(func() error { return p.record(ctxErrGroup, zone, id, chRecords) }) } if err := eg.Wait(); err != nil { return err } close(chRecords) for record := range chRecords { ovhRecords = append(ovhRecords, record) } if p.UseCache { soa.records = ovhRecords _ = p.cacheInstance.Add(*zone+"#soa", soa, cache.DefaultExpiration) } records <- ovhRecords return nil } func (p *OVHProvider) record(ctx context.Context, zone *string, id uint64, records chan<- ovhRecord) error { record := ovhRecord{} log.Debugf("OVH: Getting record %d for %s", id, *zone) p.apiRateLimiter.Take() if err := p.client.GetWithContext(ctx, fmt.Sprintf("/domain/zone/%s/record/%d", url.PathEscape(*zone), id), &record); err != nil { return err } if provider.SupportedRecordType(record.FieldType) { log.Debugf("OVH: Record %d for %s is %+v", id, *zone, record) records <- record } return nil } func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint { endpoints := []*endpoint.Endpoint{} // group supported records by name and type groups := map[string][]ovhRecord{} for _, r := range records { groupBy := r.Zone + "//" + r.SubDomain + "//" + r.FieldType if _, ok := groups[groupBy]; !ok { groups[groupBy] = []ovhRecord{} } groups[groupBy] = append(groups[groupBy], r) } // create single endpoint with all the targets for each name/type for _, records := range groups { var targets []string for _, record := range records { targets = append(targets, record.Target) } ep := endpoint.NewEndpointWithTTL( strings.TrimPrefix(records[0].SubDomain+"."+records[0].Zone, "."), records[0].FieldType, endpoint.TTL(records[0].TTL), targets..., ) endpoints = append(endpoints, ep) } return endpoints } func (p *OVHProvider) newOvhChangeCreateDelete(action int, endpoints []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, []ovhRecord) { var ovhChanges []ovhChange var toDeleteIds []int for _, e := range endpoints { for _, target := range e.Targets { change := ovhChange{ Action: action, ovhRecord: ovhRecord{ Zone: zone, ovhRecordFields: ovhRecordFields{ FieldType: e.RecordType, ovhRecordFieldUpdate: ovhRecordFieldUpdate{ SubDomain: convertDNSNameIntoSubDomain(e.DNSName, zone), TTL: defaultTTL, Target: target, }, }, }, } p.formatCNAMETarget(&change) if e.RecordTTL.IsConfigured() { change.TTL = int64(e.RecordTTL) } // The Zone might have multiple records with the same target. In order to avoid applying the action to the // same OVH record, we remove a record from the list when a match is found. if action == ovhDelete { for i, rec := range existingRecords { if rec.Zone == change.Zone && rec.SubDomain == change.SubDomain && rec.FieldType == change.FieldType && rec.Target == change.Target && !slices.Contains(toDeleteIds, i) { change.ID = rec.ID toDeleteIds = append(toDeleteIds, i) break } } } ovhChanges = append(ovhChanges, change) } } if len(toDeleteIds) > 0 { // Copy the records because we need to mutate the list. newExistingRecords := make([]ovhRecord, 0, len(existingRecords)-len(toDeleteIds)) for id := range existingRecords { if slices.Contains(toDeleteIds, id) { continue } newExistingRecords = append(newExistingRecords, existingRecords[id]) } existingRecords = newExistingRecords } return ovhChanges, existingRecords } func convertDNSNameIntoSubDomain(DNSName string, zoneName string) string { // nolint: gocritic // captLocal if DNSName == zoneName { return "" } if name, err := idna.Profile.ToUnicode(DNSName); err == nil { DNSName = name } if name, err := idna.Profile.ToUnicode(zoneName); err == nil { zoneName = name } return strings.TrimSuffix(DNSName, "."+zoneName) } func normalizeDNSName(dnsName string) string { return strings.TrimSpace(strings.ToLower(dnsName)) } func (p *OVHProvider) newOvhChangeUpdate(endpointsOld []*endpoint.Endpoint, endpointsNew []*endpoint.Endpoint, zone string, existingRecords []ovhRecord) ([]ovhChange, error) { zoneNameIDMapper := provider.ZoneIDName{} zoneNameIDMapper.Add(zone, zone) oldEndpointByTypeAndName := map[string]*endpoint.Endpoint{} newEndpointByTypeAndName := map[string]*endpoint.Endpoint{} oldRecordsInZone := map[string][]ovhRecord{} for _, e := range endpointsOld { sub := convertDNSNameIntoSubDomain(e.DNSName, zone) oldEndpointByTypeAndName[normalizeDNSName(e.RecordType+"//"+sub)] = e } for _, e := range endpointsNew { sub := convertDNSNameIntoSubDomain(e.DNSName, zone) newEndpointByTypeAndName[normalizeDNSName(e.RecordType+"//"+sub)] = e } for id := range oldEndpointByTypeAndName { for _, record := range existingRecords { if id == normalizeDNSName(record.FieldType+"//"+record.SubDomain) { oldRecordsInZone[id] = append(oldRecordsInZone[id], record) } } } var changes []ovhChange for id := range oldEndpointByTypeAndName { oldRecords := slices.Clone(oldRecordsInZone[id]) endpointsNew, ok := newEndpointByTypeAndName[id] if !ok { return nil, errors.New("unrecoverable error: couldn't find the matching record in the update.New") } var toInsertTarget []string for _, target := range endpointsNew.Targets { var toDelete = -1 for i, record := range oldRecords { if target == record.Target { toDelete = i break } } if toDelete >= 0 { oldRecords = slices.Delete(oldRecords, toDelete, toDelete+1) } else { toInsertTarget = append(toInsertTarget, target) } } createChangeConvertedToUpdateChange := []int{} for i, target := range toInsertTarget { if len(oldRecords) == 0 { break } record := oldRecords[0] oldRecords = slices.Delete(oldRecords, 0, 1) record.Target = target if endpointsNew.RecordTTL.IsConfigured() { record.TTL = int64(endpointsNew.RecordTTL) } else { record.TTL = defaultTTL } change := ovhChange{ Action: ovhUpdate, ovhRecord: record, } p.formatCNAMETarget(&change) changes = append(changes, change) createChangeConvertedToUpdateChange = append(createChangeConvertedToUpdateChange, i) } newToInsertTarget := make([]string, 0, len(toInsertTarget)-len(createChangeConvertedToUpdateChange)) for i := range toInsertTarget { if slices.Contains(createChangeConvertedToUpdateChange, i) { continue } newToInsertTarget = append(newToInsertTarget, toInsertTarget[i]) } toInsertTarget = newToInsertTarget if len(toInsertTarget) > 0 { for _, target := range toInsertTarget { recordTTL := int64(defaultTTL) if endpointsNew.RecordTTL.IsConfigured() { recordTTL = int64(endpointsNew.RecordTTL) } change := ovhChange{ Action: ovhCreate, ovhRecord: ovhRecord{ Zone: zone, ovhRecordFields: ovhRecordFields{ FieldType: endpointsNew.RecordType, ovhRecordFieldUpdate: ovhRecordFieldUpdate{ SubDomain: convertDNSNameIntoSubDomain(endpointsNew.DNSName, zone), TTL: recordTTL, Target: target, }, }, }, } p.formatCNAMETarget(&change) changes = append(changes, change) } } if len(oldRecords) > 0 { for i := range oldRecords { changes = append(changes, ovhChange{ Action: ovhDelete, ovhRecord: oldRecords[i], }) } } } return changes, nil } func (c *ovhChange) String() string { var action string switch c.Action { case ovhCreate: action = "create" case ovhUpdate: action = "update" case ovhDelete: action = "delete" default: action = "unknown" } if c.ID != 0 { return fmt.Sprintf("%s zone (ID : %d) action(%s) : %s %d IN %s %s", c.Zone, c.ID, action, c.SubDomain, c.TTL, c.FieldType, c.Target) } return fmt.Sprintf("%s zone action(%s) : %s %d IN %s %s", c.Zone, action, c.SubDomain, c.TTL, c.FieldType, c.Target) } func (p *OVHProvider) formatCNAMETarget(change *ovhChange) { if change.FieldType != endpoint.RecordTypeCNAME { return } if p.EnableCNAMERelativeTarget { return } if strings.HasSuffix(change.Target, ".") { return } change.Target += "." } ================================================ FILE: provider/ovh/ovh_test.go ================================================ /* Copyright 2017 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 ovh import ( "context" "encoding/json" "errors" "sort" "testing" "time" "github.com/maxatome/go-testdeep/td" "github.com/miekg/dns" "github.com/ovh/go-ovh/ovh" "github.com/patrickmn/go-cache" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.uber.org/ratelimit" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) type mockOvhClient struct { mock.Mock } func (c *mockOvhClient) PostWithContext(_ context.Context, endpoint string, input any, output any) error { stub := c.Called(endpoint, input) data, err := json.Marshal(stub.Get(0)) if err != nil { return err } json.Unmarshal(data, output) return stub.Error(1) } func (c *mockOvhClient) PutWithContext(_ context.Context, endpoint string, input any, output any) error { stub := c.Called(endpoint, input) data, err := json.Marshal(stub.Get(0)) if err != nil { return err } json.Unmarshal(data, output) return stub.Error(1) } func (c *mockOvhClient) GetWithContext(_ context.Context, endpoint string, output any) error { stub := c.Called(endpoint) data, err := json.Marshal(stub.Get(0)) if err != nil { return err } json.Unmarshal(data, output) return stub.Error(1) } func (c *mockOvhClient) DeleteWithContext(_ context.Context, endpoint string, output any) error { stub := c.Called(endpoint) data, err := json.Marshal(stub.Get(0)) if err != nil { return err } json.Unmarshal(data, output) return stub.Error(1) } type mockDnsClient struct { mock.Mock } func (c *mockDnsClient) ExchangeContext(ctx context.Context, m *dns.Msg, addr string) (*dns.Msg, time.Duration, error) { args := c.Called(ctx, m, addr) msg := args.Get(0).(*dns.Msg) err := args.Error(1) return msg, time.Duration(0), err } func TestOvhZones(t *testing.T) { assert := assert.New(t) client := new(mockOvhClient) provider := &OVHProvider{ client: client, apiRateLimiter: ratelimit.New(10), domainFilter: endpoint.NewDomainFilter([]string{"com"}), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), dnsClient: new(mockDnsClient), } // Basic zones client.On("GetWithContext", "/domain/zone").Return([]string{"example.com", "example.net"}, nil).Once() domains, err := provider.zones(t.Context()) assert.NoError(err) assert.Contains(domains, "example.com") assert.NotContains(domains, "example.net") client.AssertExpectations(t) // Error on getting zones client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once() domains, err = provider.zones(t.Context()) assert.Error(err) assert.Nil(domains) client.AssertExpectations(t) } func TestOvhZoneRecords(t *testing.T) { assert := assert.New(t) client := new(mockOvhClient) provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), dnsClient: nil, UseCache: true} // Basic zones records t.Log("Basic zones records") client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090901}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() zones, records, err := provider.zonesRecords(t.Context()) assert.NoError(err) assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}}) client.AssertExpectations(t) // Error on getting zones list t.Log("Error on getting zones list") client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once() zones, records, err = provider.zonesRecords(t.Context()) assert.Error(err) assert.Nil(zones) assert.Nil(records) client.AssertExpectations(t) // Error on getting zone SOA t.Log("Error on getting zone SOA") provider.cacheInstance = cache.New(cache.NoExpiration, cache.NoExpiration) client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(nil, ovh.ErrAPIDown).Once() zones, records, err = provider.zonesRecords(t.Context()) assert.Error(err) assert.Nil(zones) assert.Nil(records) client.AssertExpectations(t) // Error on getting zone records t.Log("Error on getting zone records") client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return(nil, ovh.ErrAPIDown).Once() zones, records, err = provider.zonesRecords(t.Context()) assert.Error(err) assert.Nil(zones) assert.Nil(records) client.AssertExpectations(t) // Error on getting zone record detail t.Log("Error on getting zone record detail") client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(nil, ovh.ErrAPIDown).Once() zones, records, err = provider.zonesRecords(t.Context()) assert.Error(err) assert.Nil(zones) assert.Nil(records) client.AssertExpectations(t) } func TestOvhZoneRecordsCache(t *testing.T) { assert := assert.New(t) client := new(mockOvhClient) dnsClient := new(mockDnsClient) provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), dnsClient: dnsClient, UseCache: true} // First call, cache miss t.Log("First call, cache miss") client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090901}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() zones, records, err := provider.zonesRecords(t.Context()) assert.NoError(err) assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}}) client.AssertExpectations(t) dnsClient.AssertExpectations(t) // reset mock client = new(mockOvhClient) dnsClient = new(mockDnsClient) provider.client, provider.dnsClient = client, dnsClient // second call, cache hit t.Log("second call, cache hit") client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53"). Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090901}}}, nil) zones, records, err = provider.zonesRecords(t.Context()) assert.NoError(err) assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}}) client.AssertExpectations(t) dnsClient.AssertExpectations(t) // reset mock client = new(mockOvhClient) dnsClient = new(mockDnsClient) provider.client, provider.dnsClient = client, dnsClient // third call, cache out of date t.Log("third call, cache out of date") client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53"). Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090902}}}, nil) client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090902}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() zones, records, err = provider.zonesRecords(t.Context()) assert.NoError(err) assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(records, []ovhRecord{{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}}) client.AssertExpectations(t) dnsClient.AssertExpectations(t) // reset mock client = new(mockOvhClient) dnsClient = new(mockDnsClient) provider.client, provider.dnsClient = client, dnsClient // fourth call, cache hit t.Log("fourth call, cache hit") client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53"). Return(&dns.Msg{Answer: []dns.RR{&dns.SOA{Serial: 2022090902}}}, nil) zones, records, err = provider.zonesRecords(t.Context()) assert.NoError(err) assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(records, []ovhRecord{{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}}) client.AssertExpectations(t) dnsClient.AssertExpectations(t) // reset mock client = new(mockOvhClient) dnsClient = new(mockDnsClient) provider.client, provider.dnsClient = client, dnsClient // fifth call, dns issue t.Log("fourth call, cache hit") client.On("GetWithContext", "/domain/zone").Return([]string{"example.org"}, nil).Once() dnsClient.On("ExchangeContext", mock.AnythingOfType("*context.cancelCtx"), mock.AnythingOfType("*dns.Msg"), "ns.example.org:53"). Return(&dns.Msg{Answer: []dns.RR{}}, errors.New("dns issue")) client.On("GetWithContext", "/domain/zone/example.org/soa").Return(ovhSoa{Server: "ns.example.org.", Serial: 2022090903}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() zones, records, err = provider.zonesRecords(t.Context()) assert.NoError(err) assert.ElementsMatch(zones, []string{"example.org"}) assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, {ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "NS", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}}) client.AssertExpectations(t) dnsClient.AssertExpectations(t) } func TestOvhRecords(t *testing.T) { assert := assert.New(t) client := new(mockOvhClient) provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} // Basic zones records client.On("GetWithContext", "/domain/zone").Return([]string{"example.org", "example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "CNAME", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "www", TTL: 10, Target: "example.org."}}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{24, 42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/24").Return(ovhRecord{ID: 24, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.43"}}}, nil).Once() endpoints, err := provider.Records(t.Context()) assert.NoError(err) // Little fix for multi targets endpoint for _, endpoint := range endpoints { sort.Strings(endpoint.Targets) } assert.ElementsMatch(endpoints, []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: "A", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{"203.0.113.42"}}, {DNSName: "www.example.org", RecordType: "CNAME", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{"example.org"}}, {DNSName: "ovh.example.net", RecordType: "A", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{"203.0.113.42", "203.0.113.43"}}, }) client.AssertExpectations(t) // Error getting zone client.On("GetWithContext", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once() endpoints, err = provider.Records(t.Context()) assert.Error(err) assert.Nil(endpoints) client.AssertExpectations(t) } func TestOvhComputeChanges(t *testing.T) { existingRecords := []ovhRecord{ { ID: 1, Zone: "example.net", ovhRecordFields: ovhRecordFields{ FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{ SubDomain: "", Target: "203.0.113.42", }, }, }, } changes := plan.Changes{ UpdateOld: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", Targets: []string{"203.0.113.42"}}, }, UpdateNew: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", Targets: []string{"203.0.113.43", "203.0.113.42"}}, }, } provider := &OVHProvider{client: nil, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} ovhChanges, err := provider.computeSingleZoneChanges(t.Context(), "example.net", existingRecords, &changes) td.CmpNoError(t, err) td.Cmp(t, ovhChanges, []ovhChange{ { Action: ovhCreate, ovhRecord: ovhRecord{ Zone: "example.net", ovhRecordFields: ovhRecordFields{ FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{ SubDomain: "", Target: "203.0.113.43", }, }, }, }, }) } func TestOvhRefresh(t *testing.T) { client := new(mockOvhClient) provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} // Basic zone refresh client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once() provider.refresh(t.Context(), "example.net") client.AssertExpectations(t) } func TestOvhNewChange(t *testing.T) { provider := &OVHProvider{client: nil, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} endpoints := []*endpoint.Endpoint{ {DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}}, {DNSName: "ovh2.example.net", RecordType: "CNAME", Targets: []string{"ovh.example.net"}}, {DNSName: "test.example.org"}, } // Create change changes, _ := provider.newOvhChangeCreateDelete(ovhCreate, endpoints, "example.net", []ovhRecord{}) td.Cmp(t, changes, []ovhChange{ {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: defaultTTL, Target: "203.0.113.43"}}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "CNAME", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh2", TTL: defaultTTL, Target: "ovh.example.net."}}}}, }) // Delete change endpoints = []*endpoint.Endpoint{ {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.42", "203.0.113.42", "203.0.113.43"}}, } records := []ovhRecord{ {ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", Target: "203.0.113.43"}}}, {ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", Target: "203.0.113.42"}}}, {ID: 44, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", Target: "203.0.113.42"}}}, } changes, _ = provider.newOvhChangeCreateDelete(ovhDelete, endpoints, "example.net", records) td.Cmp(t, changes, []ovhChange{ {Action: ovhDelete, ovhRecord: ovhRecord{ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: defaultTTL, Target: "203.0.113.42"}}}}, {Action: ovhDelete, ovhRecord: ovhRecord{ID: 44, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: defaultTTL, Target: "203.0.113.42"}}}}, {Action: ovhDelete, ovhRecord: ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: defaultTTL, Target: "203.0.113.43"}}}}, }) // Create change with CNAME relative endpoints = []*endpoint.Endpoint{ {DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}}, {DNSName: "ovh2.example.net", RecordType: "CNAME", Targets: []string{"ovh"}}, {DNSName: "test.example.org"}, } provider = &OVHProvider{client: nil, EnableCNAMERelativeTarget: true, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} changes, _ = provider.newOvhChangeCreateDelete(ovhCreate, endpoints, "example.net", []ovhRecord{}) td.Cmp(t, changes, []ovhChange{ {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: defaultTTL, Target: "203.0.113.43"}}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "CNAME", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh2", TTL: defaultTTL, Target: "ovh"}}}}, }) // Test with CNAME when target has already final dot endpoints = []*endpoint.Endpoint{ {DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}}, {DNSName: "ovh2.example.net", RecordType: "CNAME", Targets: []string{"ovh.example.com."}}, {DNSName: "test.example.org"}, } provider = &OVHProvider{client: nil, EnableCNAMERelativeTarget: false, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} changes, _ = provider.newOvhChangeCreateDelete(ovhCreate, endpoints, "example.net", []ovhRecord{}) td.Cmp(t, changes, []ovhChange{ {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: defaultTTL, Target: "203.0.113.43"}}}}, {Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "CNAME", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh2", TTL: defaultTTL, Target: "ovh.example.com."}}}}, }) } func TestOvhApplyChanges(t *testing.T) { client := new(mockOvhClient) provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} changes := plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, }, Delete: []*endpoint.Endpoint{ {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}}, }, } client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.43"}}}, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}).Return(nil, nil).Once() client.On("DeleteWithContext", "/domain/zone/example.net/record/42").Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once() _, err := provider.Records(t.Context()) td.CmpNoError(t, err) // Basic changes td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes)) client.AssertExpectations(t) // Apply change failed client = new(mockOvhClient) provider.client = client client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}).Return(nil, ovh.ErrAPIDown).Once() _, err = provider.Records(t.Context()) td.CmpNoError(t, err) td.CmpError(t, provider.ApplyChanges(t.Context(), &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, }, })) client.AssertExpectations(t) // Refresh failed client = new(mockOvhClient) provider.client = client client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}).Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, ovh.ErrAPIDown).Once() _, err = provider.Records(t.Context()) td.CmpNoError(t, err) td.CmpError(t, provider.ApplyChanges(t.Context(), &plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, }, })) client.AssertExpectations(t) // Test Dry-Run client = new(mockOvhClient) provider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: true} changes = plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, }, Delete: []*endpoint.Endpoint{ {DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}}, }, } client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.43"}}}, nil).Once() _, err = provider.Records(t.Context()) td.CmpNoError(t, err) td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes)) client.AssertExpectations(t) // Test Update client = new(mockOvhClient) provider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: false} changes = plan.Changes{ UpdateOld: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, }, UpdateNew: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.43"}}, }, } client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() client.On("PutWithContext", "/domain/zone/example.net/record/42", ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.43"}).Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once() _, err = provider.Records(t.Context()) td.CmpNoError(t, err) td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes)) client.AssertExpectations(t) // Test Update DryRun client = new(mockOvhClient) provider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: true} changes = plan.Changes{ UpdateOld: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, }, UpdateNew: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.43"}}, }, } client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() _, err = provider.Records(t.Context()) td.CmpNoError(t, err) td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes)) client.AssertExpectations(t) // Test Update 2 records => 1 record client = new(mockOvhClient) provider = &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration), DryRun: false} changes = plan.Changes{ UpdateOld: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42", "203.0.113.43"}}, }, UpdateNew: []*endpoint.Endpoint{ {DNSName: "example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.43"}}, }, } client.On("GetWithContext", "/domain/zone").Return([]string{"example.net"}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record").Return([]uint64{42, 43}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.42"}}}, nil).Once() client.On("GetWithContext", "/domain/zone/example.net/record/43").Return(ovhRecord{ID: 43, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "", TTL: 10, Target: "203.0.113.43"}}}, nil).Once() client.On("DeleteWithContext", "/domain/zone/example.net/record/42").Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once() _, err = provider.Records(t.Context()) td.CmpNoError(t, err) td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes)) client.AssertExpectations(t) } func TestOvhApplyChangesPunyCode(t *testing.T) { client := new(mockOvhClient) provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} changes := plan.Changes{ Create: []*endpoint.Endpoint{ {DNSName: "example.testécassé.fr", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}}, }, } client.On("GetWithContext", "/domain/zone").Return([]string{"xn--testcass-e1ae.fr"}, nil).Once() client.On("GetWithContext", "/domain/zone/xn--testcass-e1ae.fr/record").Return([]uint64{}, nil).Once() client.On("PostWithContext", "/domain/zone/xn--testcass-e1ae.fr/record", ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "example", TTL: 10, Target: "203.0.113.42"}}).Return(nil, nil).Once() client.On("PostWithContext", "/domain/zone/xn--testcass-e1ae.fr/refresh", nil).Return(nil, nil).Once() _, err := provider.Records(t.Context()) td.CmpNoError(t, err) // Basic changes td.CmpNoError(t, provider.ApplyChanges(t.Context(), &changes)) client.AssertExpectations(t) } func TestOvhChange(t *testing.T) { assert := assert.New(t) client := new(mockOvhClient) provider := &OVHProvider{client: client, apiRateLimiter: ratelimit.New(10), cacheInstance: cache.New(cache.NoExpiration, cache.NoExpiration)} // Record creation client.On("PostWithContext", "/domain/zone/example.net/record", ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}).Return(nil, nil).Once() assert.NoError(provider.change(t.Context(), ovhChange{ Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}}, })) client.AssertExpectations(t) // Record deletion client.On("DeleteWithContext", "/domain/zone/example.net/record/42").Return(nil, nil).Once() assert.NoError(provider.change(t.Context(), ovhChange{ Action: ovhDelete, ovhRecord: ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}}, })) client.AssertExpectations(t) // Record deletion error assert.Error(provider.change(t.Context(), ovhChange{ Action: ovhDelete, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh"}}}, })) client.AssertExpectations(t) } func TestOvhRecordString(t *testing.T) { record := ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{FieldType: "A", ovhRecordFieldUpdate: ovhRecordFieldUpdate{SubDomain: "ovh", TTL: 10, Target: "203.0.113.42"}}} td.Cmp(t, record.String(), "record#24: A | ovh => 203.0.113.42 (10)") } func TestNewOvhProvider(t *testing.T) { domainFilter := &endpoint.DomainFilter{} _, err := newProvider(domainFilter, "ovh-eu", 20, false, true) td.CmpError(t, err) t.Setenv("OVH_APPLICATION_KEY", "aaaaaa") t.Setenv("OVH_APPLICATION_SECRET", "bbbbbb") t.Setenv("OVH_CONSUMER_KEY", "cccccc") _, err = newProvider(domainFilter, "ovh-eu", 20, false, true) td.CmpNoError(t, err) } ================================================ FILE: provider/pdns/pdns.go ================================================ /* Copyright 2018 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 pdns import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "math" "net" "net/http" "slices" "sort" "strings" "time" pgo "github.com/ffledgling/pdns-go" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/tlsutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) type pdnsChangeType string const ( apiBase = "/api/v1" defaultTTL = 300 // PdnsDelete and PdnsReplace are effectively an enum for "pgo.RrSet.changetype" // TODO: Can we somehow get this from the pgo swagger client library itself? // PdnsDelete : PowerDNS changetype used for deleting rrsets // ref: https://doc.powerdns.com/authoritative/http-api/zone.html#rrset (see "changetype") PdnsDelete pdnsChangeType = "DELETE" // PdnsReplace : PowerDNS changetype for creating, updating and patching rrsets PdnsReplace pdnsChangeType = "REPLACE" // Number of times to retry failed PDNS requests retryLimit = 3 // time in milliseconds retryAfterTime = 250 * time.Millisecond ) // record types which require to have trailing dot var trailingTypes = []string{ endpoint.RecordTypeCNAME, endpoint.RecordTypeMX, endpoint.RecordTypeSRV, endpoint.RecordTypeNS, endpoint.RecordTypePTR, "ALIAS", } // PDNSConfig is comprised of the fields necessary to create a new PDNSProvider type PDNSConfig struct { DomainFilter *endpoint.DomainFilter DryRun bool Server string ServerID string APIKey string TLSConfig TLSConfig } // TLSConfig is comprised of the TLS-related fields necessary to create a new PDNSProvider type TLSConfig struct { SkipTLSVerify bool CAFilePath string ClientCertFilePath string ClientCertKeyFilePath string } func (tlsConfig *TLSConfig) setHTTPClient(pdnsClientConfig *pgo.Configuration) error { log.Debug("Configuring TLS for PDNS Provider.") tlsClientConfig, err := tlsutils.NewTLSConfig( tlsConfig.ClientCertFilePath, tlsConfig.ClientCertKeyFilePath, tlsConfig.CAFilePath, "", tlsConfig.SkipTLSVerify, tls.VersionTLS12, ) if err != nil { return err } // Timeouts taken from net.http.DefaultTransport transporter := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, DualStack: true, }).DialContext, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, TLSClientConfig: tlsClientConfig, } pdnsClientConfig.HTTPClient = &http.Client{ Transport: transporter, } return nil } // Function for debug printing func stringifyHTTPResponseBody(r *http.Response) string { if r == nil { return "" } buf := new(bytes.Buffer) _, _ = buf.ReadFrom(r.Body) return buf.String() } // PDNSAPIProvider : Interface used and extended by the PDNSAPIClient struct as // well as mock APIClients used in testing type PDNSAPIProvider interface { ListZones() ([]pgo.Zone, *http.Response, error) ListZone(zoneID string) (pgo.Zone, *http.Response, error) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) } // PDNSAPIClient : Struct that encapsulates all the PowerDNS specific implementation details type PDNSAPIClient struct { dryRun bool serverID string authCtx context.Context client *pgo.APIClient } // ListZones : Method returns all enabled zones from PowerDNS // ref: https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones func (c *PDNSAPIClient) ListZones() ([]pgo.Zone, *http.Response, error) { var zones []pgo.Zone var resp *http.Response var err error for i := range retryLimit { zones, resp, err = c.client.ZonesApi.ListZones(c.authCtx, c.serverID) if err != nil { log.Debugf("Unable to fetch zones %v", err) log.Debugf("Retrying ListZones() ... %d", i) time.Sleep(retryAfterTime * (1 << uint(i))) continue } return zones, resp, err } return zones, resp, provider.NewSoftErrorf("unable to list zones: %v", err) } // partitionZones returns a slice of zones that adhere to the domain filter and a slice of ones that do not adhere to the filter. func partitionZones(zones []pgo.Zone, domainFilter *endpoint.DomainFilter) ([]pgo.Zone, []pgo.Zone) { if domainFilter == nil || !domainFilter.IsConfigured() { return zones, nil } var filtered, residual []pgo.Zone for _, zone := range zones { if domainFilter.Match(zone.Name) { filtered = append(filtered, zone) } else { residual = append(residual, zone) } } return filtered, residual } // ListZone : Method returns the details of a specific zone from PowerDNS // ref: https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones-zone_id func (c *PDNSAPIClient) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { for i := range retryLimit { zone, resp, err := c.client.ZonesApi.ListZone(c.authCtx, c.serverID, zoneID) if err != nil { log.Debugf("Unable to fetch zone %v", err) log.Debugf("Retrying ListZone() ... %d", i) time.Sleep(retryAfterTime * (1 << uint(i))) continue } return zone, resp, err } return pgo.Zone{}, nil, provider.NewSoftErrorf("unable to list zone") } // PatchZone : Method used to update the contents of a particular zone from PowerDNS // ref: https://doc.powerdns.com/authoritative/http-api/zone.html#patch--servers-server_id-zones-zone_id func (c *PDNSAPIClient) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) { var resp *http.Response var err error for i := range retryLimit { resp, err = c.client.ZonesApi.PatchZone(c.authCtx, c.serverID, zoneID, zoneStruct) if err != nil { log.Debugf("Unable to patch zone %v", err) log.Debugf("Retrying PatchZone() ... %d", i) time.Sleep(retryAfterTime * (1 << uint(i))) continue } return resp, err } return resp, provider.NewSoftErrorf("unable to patch zone: %v", err) } // PDNSProvider is an implementation of the Provider interface for PowerDNS type PDNSProvider struct { provider.BaseProvider client PDNSAPIProvider domainFilter *endpoint.DomainFilter } // New creates a PowerDNS provider from the given configuration. func New(ctx context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider( ctx, PDNSConfig{ DomainFilter: domainFilter, DryRun: cfg.DryRun, Server: cfg.PDNSServer, ServerID: cfg.PDNSServerID, APIKey: cfg.PDNSAPIKey, TLSConfig: TLSConfig{ SkipTLSVerify: cfg.PDNSSkipTLSVerify, CAFilePath: cfg.TLSCA, ClientCertFilePath: cfg.TLSClientCert, ClientCertKeyFilePath: cfg.TLSClientCertKey, }, }, ) } // newProvider initializes a new PowerDNS based Provider. func newProvider(ctx context.Context, config PDNSConfig) (*PDNSProvider, error) { // Do some input validation if config.APIKey == "" { return nil, errors.New("missing API Key for PDNS. Specify using --pdns-api-key=") } // We do not support dry running, exit safely instead of surprising the user // TODO: Add Dry Run support if config.DryRun { return nil, errors.New("PDNS Provider does not currently support dry-run") } if config.Server == "localhost" { log.Warnf("PDNS Server is set to localhost, this may not be what you want. Specify using --pdns-server=") } pdnsClientConfig := pgo.NewConfiguration() pdnsClientConfig.BasePath = config.Server + apiBase if err := config.TLSConfig.setHTTPClient(pdnsClientConfig); err != nil { return nil, err } provider := &PDNSProvider{ client: &PDNSAPIClient{ dryRun: config.DryRun, serverID: config.ServerID, authCtx: context.WithValue(ctx, pgo.ContextAPIKey, pgo.APIKey{Key: config.APIKey}), client: pgo.NewAPIClient(pdnsClientConfig), }, domainFilter: config.DomainFilter, } return provider, nil } // filteredZones fetches all zones from the PowerDNS API and partitions them // using the provider's domain filter. It returns the matching zones, the // non-matching (residual) zones, and any error from the API call. func (p *PDNSProvider) filteredZones() ([]pgo.Zone, []pgo.Zone, error) { zones, _, err := p.client.ListZones() if err != nil { return nil, nil, err } filtered, residual := partitionZones(zones, p.domainFilter) return filtered, residual, nil } func (p *PDNSProvider) GetDomainFilter() endpoint.DomainFilterInterface { // Return all zones the provider manages so the controller can intersect // with --domain-filter on its own. Do NOT apply p.domainFilter here; // double-filtering would produce an empty filter when no zones match, // silently failing open instead of letting the controller see the // mismatch and produce a safe empty plan. zones, _, err := p.client.ListZones() if err != nil { log.Errorf("Unable to fetch zones from PowerDNS API: %v", err) return &endpoint.DomainFilter{} } zoneNames := make([]string, 0, 2*len(zones)) for _, zone := range zones { zoneNames = append(zoneNames, zone.Name, "."+zone.Name) } return endpoint.NewDomainFilter(zoneNames) } // hasAliasAnnotation checks if the endpoint has the alias annotation set to true func (p *PDNSProvider) hasAliasAnnotation(ep *endpoint.Endpoint) bool { value, exists := ep.GetProviderSpecificProperty("alias") return exists && value == "true" } func (p *PDNSProvider) convertRRSetToEndpoints(rr pgo.RrSet) []*endpoint.Endpoint { endpoints := make([]*endpoint.Endpoint, 0) targets := make([]string, 0) rrType_ := rr.Type_ for _, record := range rr.Records { // If a record is "Disabled", it's not supposed to be "visible" if !record.Disabled { targets = append(targets, record.Content) } } if rr.Type_ == "ALIAS" { rrType_ = endpoint.RecordTypeCNAME } endpoints = append(endpoints, endpoint.NewEndpointWithTTL(rr.Name, rrType_, endpoint.TTL(rr.Ttl), targets...)) return endpoints } // ConvertEndpointsToZones marshals endpoints into pdns compatible Zone structs func (p *PDNSProvider) ConvertEndpointsToZones(eps []*endpoint.Endpoint, changetype pdnsChangeType) ([]pgo.Zone, error) { var zoneList = make([]pgo.Zone, 0) endpoints := make([]*endpoint.Endpoint, len(eps)) copy(endpoints, eps) // Sort the endpoints array so we have deterministic inserts sort.SliceStable(endpoints, func(i, j int) bool { // We only care about sorting endpoints with the same dnsname if endpoints[i].DNSName == endpoints[j].DNSName { return endpoints[i].RecordType < endpoints[j].RecordType } return endpoints[i].DNSName < endpoints[j].DNSName }) filteredZones, residualZones, err := p.filteredZones() if err != nil { return nil, err } // Sort the zone by length of the name in descending order, we use this // property later to ensure we add a record to the longest matching zone sort.SliceStable(filteredZones, func(i, j int) bool { return len(filteredZones[i].Name) > len(filteredZones[j].Name) }) // NOTE: Complexity of this loop is O(FilteredZones*Endpoints). // A possibly faster implementation would be a search of the reversed // DNSName in a trie of Zone names, which should be O(Endpoints), but at this point it's not // necessary. for _, zone := range filteredZones { zone.Rrsets = []pgo.RrSet{} for i := 0; i < len(endpoints); { ep := endpoints[i] dnsname := provider.EnsureTrailingDot(ep.DNSName) if dnsname == zone.Name || strings.HasSuffix(dnsname, "."+zone.Name) { // The assumption here is that there will only ever be one target // per (ep.DNSName, ep.RecordType) tuple, which holds true for // external-dns v5.0.0-alpha onwards records := []pgo.Record{} RecordType_ := ep.RecordType for _, t := range ep.Targets { if slices.Contains(trailingTypes, ep.RecordType) { t = provider.EnsureTrailingDot(t) } records = append(records, pgo.Record{Content: t}) } // Check if we should use ALIAS instead of CNAME: // 1. APEX records (dnsname == zone.Name) always use ALIAS // 2. If annotation external-dns.alpha.kubernetes.io/alias=true is set // (can be set via --prefer-alias flag globally or per-resource annotation) if ep.RecordType == endpoint.RecordTypeCNAME { useAlias := dnsname == zone.Name || p.hasAliasAnnotation(ep) if useAlias { log.Debugf("Converting CNAME record %q to ALIAS", dnsname) RecordType_ = "ALIAS" } } rrset := pgo.RrSet{ Name: dnsname, Type_: RecordType_, Records: records, Changetype: string(changetype), } // DELETEs explicitly forbid a TTL, therefore only PATCHes need the TTL if changetype == PdnsReplace { if int64(ep.RecordTTL) > int64(math.MaxInt32) { return nil, provider.NewSoftErrorf("value of record TTL overflows, limited to int32") } if ep.RecordTTL == 0 { // No TTL was specified for the record, we use the default rrset.Ttl = int32(defaultTTL) } else { rrset.Ttl = int32(ep.RecordTTL) } } zone.Rrsets = append(zone.Rrsets, rrset) // "pop" endpoint if it's matched endpoints = append(endpoints[0:i], endpoints[i+1:]...) } else { // If we didn't pop anything, we move to the next item in the list i++ } } if len(zone.Rrsets) > 0 { zoneList = append(zoneList, zone) } } // residualZones is unsorted by name length like its counterpart // since we only care to remove endpoints that do not match domain filter for _, zone := range residualZones { for i := 0; i < len(endpoints); { ep := endpoints[i] dnsname := provider.EnsureTrailingDot(ep.DNSName) if dnsname == zone.Name || strings.HasSuffix(dnsname, "."+zone.Name) { // "pop" endpoint if it's matched to a residual zone... essentially a no-op log.Debugf("Ignoring Endpoint because it was matched to a zone that was not specified within Domain Filter(s): %s", dnsname) endpoints = append(endpoints[0:i], endpoints[i+1:]...) } else { i++ } } } // If we still have some endpoints left, it means we couldn't find a matching zone (filtered or residual) for them // We warn instead of hard fail here because we don't want a misconfig to cause everything to go down if len(endpoints) > 0 { log.Warnf("No matching zones were found for the following endpoints: %+v", endpoints) } log.Debugf("Zone List generated from Endpoints: %+v", zoneList) return zoneList, nil } // mutateRecords takes a list of endpoints and creates, replaces or deletes them based on the changetype func (p *PDNSProvider) mutateRecords(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) error { zonelist, err := p.ConvertEndpointsToZones(endpoints, changetype) if err != nil { return err } for _, zone := range zonelist { jso, err := json.Marshal(zone) if err != nil { log.Errorf("JSON Marshal for zone struct failed!") } else { log.Debugf("Struct for PatchZone:\n%s", string(jso)) } resp, err := p.client.PatchZone(zone.Id, zone) if err != nil { log.Debugf("PDNS API response: %s", stringifyHTTPResponseBody(resp)) return err } } return nil } // Records returns all DNS records controlled by the configured PDNS server (for all zones) func (p *PDNSProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { filteredZones, _, err := p.filteredZones() if err != nil { return nil, err } var endpoints []*endpoint.Endpoint for _, zone := range filteredZones { z, _, err := p.client.ListZone(zone.Id) if err != nil { return nil, provider.NewSoftErrorf("unable to fetch records: %v", err) } for _, rr := range z.Rrsets { endpoints = append(endpoints, p.convertRRSetToEndpoints(rr)...) } } log.Debugf("Records fetched:\n%+v", endpoints) return endpoints, nil } // AdjustEndpoints performs checks on the provided endpoints and will skip any potentially failing changes. func (p *PDNSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { var validEndpoints []*endpoint.Endpoint for i := range endpoints { if !endpoints[i].CheckEndpoint() { log.Warnf("Ignoring Endpoint because of invalid %v record formatting: {Target: '%v'}", endpoints[i].RecordType, endpoints[i].Targets) continue } validEndpoints = append(validEndpoints, endpoints[i]) } return validEndpoints, nil } // ApplyChanges takes a list of changes (endpoints) and updates the PDNS server // by sending the correct HTTP PATCH requests to a matching zone func (p *PDNSProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { startTime := time.Now() // Create for _, change := range changes.Create { log.Infof("CREATE: %+v", change) } // We only attempt to mutate records if there are any to mutate. A // call to mutate records with an empty list of endpoints is still a // valid call and a no-op, but we might as well not make the call to // prevent unnecessary logging if len(changes.Create) > 0 { // "Replacing" non-existent records creates them err := p.mutateRecords(changes.Create, PdnsReplace) if err != nil { return err } } // Update for _, change := range changes.UpdateOld { // Since PDNS "Patches", we don't need to specify the "old" // record. The Update New change type will automatically take // care of replacing the old RRSet with the new one We simply // leave this logging here for information log.Debugf("UPDATE-OLD (ignored): %+v", change) } for _, change := range changes.UpdateNew { log.Infof("UPDATE-NEW: %+v", change) } if len(changes.UpdateNew) > 0 { err := p.mutateRecords(changes.UpdateNew, PdnsReplace) if err != nil { return err } } // Delete for _, change := range changes.Delete { log.Infof("DELETE: %+v", change) } if len(changes.Delete) > 0 { err := p.mutateRecords(changes.Delete, PdnsDelete) if err != nil { return err } } log.Infof("Changes pushed out to PowerDNS in %s\n", time.Since(startTime)) return nil } ================================================ FILE: provider/pdns/pdns_test.go ================================================ /* Copyright 2018 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 pdns import ( "context" "net/http" "regexp" "strings" "testing" pgo "github.com/ffledgling/pdns-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/provider" ) // FIXME: What do we do about labels? var ( // Simple RRSets that contain 1 A record and 1 TXT record RRSetSimpleARecord = pgo.RrSet{ Name: "example.com.", Type_: endpoint.RecordTypeA, Ttl: 300, Records: []pgo.Record{ {Content: "8.8.8.8", Disabled: false, SetPtr: false}, }, } RRSetSimpleTXTRecord = pgo.RrSet{ Name: "example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Records: []pgo.Record{ {Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false}, }, } RRSetLongARecord = pgo.RrSet{ Name: "a.very.long.domainname.example.com.", Type_: endpoint.RecordTypeA, Ttl: 300, Records: []pgo.Record{ {Content: "8.8.8.8", Disabled: false, SetPtr: false}, }, } RRSetLongTXTRecord = pgo.RrSet{ Name: "a.very.long.domainname.example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Records: []pgo.Record{ {Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false}, }, } // RRSet with one record disabled RRSetDisabledRecord = pgo.RrSet{ Name: "example.com.", Type_: endpoint.RecordTypeA, Ttl: 300, Records: []pgo.Record{ {Content: "8.8.8.8", Disabled: false, SetPtr: false}, {Content: "8.8.4.4", Disabled: true, SetPtr: false}, }, } RRSetCNAMERecord = pgo.RrSet{ Name: "cname.example.com.", Type_: endpoint.RecordTypeCNAME, Ttl: 300, Records: []pgo.Record{ {Content: "example.com.", Disabled: false, SetPtr: false}, }, } RRSetALIASRecord = pgo.RrSet{ Name: "alias.example.com.", Type_: "ALIAS", Ttl: 300, Records: []pgo.Record{ {Content: "example.by.any.other.name.com.", Disabled: false, SetPtr: false}, }, } RRSetTXTRecord = pgo.RrSet{ Name: "example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Records: []pgo.Record{ {Content: "'would smell as sweet'", Disabled: false, SetPtr: false}, }, } // Multiple PDNS records in an RRSet of a single type RRSetMultipleRecords = pgo.RrSet{ Name: "example.com.", Type_: endpoint.RecordTypeA, Ttl: 300, Records: []pgo.Record{ {Content: "8.8.8.8", Disabled: false, SetPtr: false}, {Content: "8.8.4.4", Disabled: false, SetPtr: false}, {Content: "4.4.4.4", Disabled: false, SetPtr: false}, }, } // RRSet with MX record RRSetMXRecord = pgo.RrSet{ Name: "example.com.", Type_: endpoint.RecordTypeMX, Ttl: 300, Records: []pgo.Record{ {Content: "10 mailhost1.example.com", Disabled: false, SetPtr: false}, {Content: "10 mailhost2.example.com", Disabled: false, SetPtr: false}, }, } // RRSet with SRV record RRSetSRVRecord = pgo.RrSet{ Name: "_service._tls.example.com.", Type_: endpoint.RecordTypeSRV, Ttl: 300, Records: []pgo.Record{ {Content: "100 1 443 service.example.com", Disabled: false, SetPtr: false}, }, } // RRSet with NS record RRSetNSRecord = pgo.RrSet{ Name: "sub.example.com.", Type_: endpoint.RecordTypeNS, Ttl: 300, Records: []pgo.Record{ {Content: "ns1.example.com", Disabled: false, SetPtr: false}, {Content: "ns2.example.com", Disabled: false, SetPtr: false}, }, } // RRSet with PTR record RRSetPTRRecord = pgo.RrSet{ Name: "4.3.2.1.in-addr.arpa.", Type_: endpoint.RecordTypePTR, Ttl: 300, Records: []pgo.Record{ {Content: "host.example.com", Disabled: false, SetPtr: false}, }, } endpointsDisabledRecord = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), } endpointsSimpleRecord = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), } endpointsLongRecord = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("a.very.long.domainname.example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), endpoint.NewEndpointWithTTL("a.very.long.domainname.example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), } endpointsNonexistantZone = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("does.not.exist.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), endpoint.NewEndpointWithTTL("does.not.exist.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), } endpointsMultipleRecords = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8", "8.8.4.4", "4.4.4.4"), } endpointsMXRecord = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "10 example.com"), } endpointsMXRecordInvalidFormatTooManyArgs = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "10 example.com abc"), } endpointsMultipleMXRecordsWithSingleInvalid = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "abc example.com"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "20 backup.example.com"), } endpointsMultipleInvalidMXRecords = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "example.com"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "backup.example.com"), } endpointsMixedRecords = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("cname.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(300), "example.com"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "'would smell as sweet'"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8", "8.8.4.4", "4.4.4.4"), endpoint.NewEndpointWithTTL("alias.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(300), "example.by.any.other.name.com"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "10 mailhost1.example.com", "10 mailhost2.example.com"), endpoint.NewEndpointWithTTL("_service._tls.example.com", endpoint.RecordTypeSRV, endpoint.TTL(300), "100 1 443 service.example.com"), endpoint.NewEndpointWithTTL("sub.example.com", endpoint.RecordTypeNS, endpoint.TTL(300), "ns1.example.com", "ns2.example.com"), endpoint.NewEndpointWithTTL("4.3.2.1.in-addr.arpa", endpoint.RecordTypePTR, endpoint.TTL(300), "host.example.com"), } endpointsMultipleZones = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), endpoint.NewEndpointWithTTL("mock.test", endpoint.RecordTypeA, endpoint.TTL(300), "9.9.9.9"), endpoint.NewEndpointWithTTL("mock.test", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), } endpointsMultipleZones2 = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), endpoint.NewEndpointWithTTL("abcd.mock.test", endpoint.RecordTypeA, endpoint.TTL(300), "9.9.9.9"), endpoint.NewEndpointWithTTL("abcd.mock.test", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), } endpointsMultipleZonesWithNoExist = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), endpoint.NewEndpointWithTTL("abcd.mock.noexist", endpoint.RecordTypeA, endpoint.TTL(300), "9.9.9.9"), endpoint.NewEndpointWithTTL("abcd.mock.noexist", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), } endpointsMultipleZonesWithLongRecordNotInDomainFilter = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), endpoint.NewEndpointWithTTL("a.very.long.domainname.example.com", endpoint.RecordTypeA, endpoint.TTL(300), "9.9.9.9"), endpoint.NewEndpointWithTTL("a.very.long.domainname.example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), } endpointsMultipleZonesWithSimilarRecordNotInDomainFilter = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(300), "8.8.8.8"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), endpoint.NewEndpointWithTTL("test.simexample.com", endpoint.RecordTypeA, endpoint.TTL(300), "9.9.9.9"), endpoint.NewEndpointWithTTL("test.simexample.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), } endpointsApexRecords = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("cname.example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), endpoint.NewEndpointWithTTL("cname.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(300), "example.by.any.other.name.com"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(300), "\"heritage=external-dns,external-dns/owner=tower-pdns\""), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeCNAME, endpoint.TTL(300), "example.by.any.other.name.com"), } // Endpoint with alias annotation endpointWithAliasAnnotation = endpoint.NewEndpointWithTTL("sub.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(300), "target.example.com").WithProviderSpecific("alias", "true") // Endpoints for preferAlias test endpointsPreferAlias = []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("sub.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(300), "target.example.com"), } ZoneEmptyToPreferAliasPatch = pgo.Zone{ Id: "example.com.", Name: "example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/example.com.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "sub.example.com.", Type_: "ALIAS", Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "target.example.com.", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } ZoneEmptyToCNAMEPatch = pgo.Zone{ Id: "example.com.", Name: "example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/example.com.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "sub.example.com.", Type_: endpoint.RecordTypeCNAME, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "target.example.com.", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } ZoneEmpty = pgo.Zone{ // Opaque zone id (string), assigned by the server, should not be interpreted by the application. Guaranteed to be safe for embedding in URLs. Id: "example.com.", // Name of the zone (e.g. “example.com.”) MUST have a trailing dot Name: "example.com.", // Set to “Zone” Type_: "Zone", // API endpoint for this zone Url: "/api/v1/servers/localhost/zones/example.com.", // Zone kind, one of “Native”, “Master”, “Slave” Kind: "Native", // RRSets in this zone Rrsets: []pgo.RrSet{}, } ZoneEmptySimilar = pgo.Zone{ Id: "simexample.com.", Name: "simexample.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/simexample.com.", Kind: "Native", Rrsets: []pgo.RrSet{}, } ZoneEmptyLong = pgo.Zone{ Id: "long.domainname.example.com.", Name: "long.domainname.example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/long.domainname.example.com.", Kind: "Native", Rrsets: []pgo.RrSet{}, } ZoneEmpty2 = pgo.Zone{ Id: "mock.test.", Name: "mock.test.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/mock.test.", Kind: "Native", Rrsets: []pgo.RrSet{}, } ZoneMixed = pgo.Zone{ Id: "example.com.", Name: "example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/example.com.", Kind: "Native", Rrsets: []pgo.RrSet{RRSetCNAMERecord, RRSetTXTRecord, RRSetMultipleRecords, RRSetALIASRecord, RRSetMXRecord, RRSetSRVRecord, RRSetNSRecord, RRSetPTRRecord}, } ZoneEmptyToSimplePatch = pgo.Zone{ Id: "example.com.", Name: "example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/example.com.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "example.com.", Type_: endpoint.RecordTypeA, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "8.8.8.8", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } ZoneEmptyToSimplePatchLongRecordIgnoredInDomainFilter = pgo.Zone{ Id: "example.com.", Name: "example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/example.com.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "a.very.long.domainname.example.com.", Type_: endpoint.RecordTypeA, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "9.9.9.9", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "a.very.long.domainname.example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "example.com.", Type_: endpoint.RecordTypeA, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "8.8.8.8", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } ZoneEmptyToLongPatch = pgo.Zone{ Id: "long.domainname.example.com.", Name: "long.domainname.example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/long.domainname.example.com.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "a.very.long.domainname.example.com.", Type_: endpoint.RecordTypeA, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "8.8.8.8", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "a.very.long.domainname.example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } ZoneEmptyToSimplePatch2 = pgo.Zone{ Id: "mock.test.", Name: "mock.test.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/mock.test.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "mock.test.", Type_: endpoint.RecordTypeA, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "9.9.9.9", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "mock.test.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } ZoneEmptyToSimplePatch3 = pgo.Zone{ Id: "mock.test.", Name: "mock.test.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/mock.test.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "abcd.mock.test.", Type_: endpoint.RecordTypeA, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "9.9.9.9", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "abcd.mock.test.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } ZoneEmptyToSimpleDelete = pgo.Zone{ Id: "example.com.", Name: "example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/example.com.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "example.com.", Type_: endpoint.RecordTypeA, Changetype: "DELETE", Records: []pgo.Record{ { Content: "8.8.8.8", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "example.com.", Type_: endpoint.RecordTypeTXT, Changetype: "DELETE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } ZoneEmptyToApexPatch = pgo.Zone{ Id: "example.com.", Name: "example.com.", Type_: "Zone", Url: "/api/v1/servers/localhost/zones/example.com.", Kind: "Native", Rrsets: []pgo.RrSet{ { Name: "cname.example.com.", Type_: endpoint.RecordTypeCNAME, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "example.by.any.other.name.com.", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "cname.example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "example.com.", Type_: "ALIAS", Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "example.by.any.other.name.com.", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, { Name: "example.com.", Type_: endpoint.RecordTypeTXT, Ttl: 300, Changetype: "REPLACE", Records: []pgo.Record{ { Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false, }, }, Comments: []pgo.Comment(nil), }, }, } DomainFilterListSingle = endpoint.NewDomainFilter([]string{"example.com"}) DomainFilterListMultiple = endpoint.NewDomainFilter([]string{"example.com", "mock.com"}) DomainFilterListEmpty = endpoint.NewDomainFilter([]string{}) RegexDomainFilter = endpoint.NewRegexDomainFilter(regexp.MustCompile("example.com"), nil) ) /******************************************************************************/ // API that returns a zone with multiple record types type PDNSAPIClientStub struct{} func (c *PDNSAPIClientStub) ListZones() ([]pgo.Zone, *http.Response, error) { return []pgo.Zone{ZoneMixed}, nil, nil } func (c *PDNSAPIClientStub) ListZone(_ string) (pgo.Zone, *http.Response, error) { return ZoneMixed, nil, nil } func (c *PDNSAPIClientStub) PatchZone(_ string, _ pgo.Zone) (*http.Response, error) { return &http.Response{}, nil } /******************************************************************************/ // API that returns a zones with no records type PDNSAPIClientStubEmptyZones struct { // Keep track of all zones we receive via PatchZone patchedZones []pgo.Zone } func (c *PDNSAPIClientStubEmptyZones) ListZones() ([]pgo.Zone, *http.Response, error) { return []pgo.Zone{ZoneEmpty, ZoneEmptyLong, ZoneEmpty2}, nil, nil } func (c *PDNSAPIClientStubEmptyZones) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { switch { case strings.Contains(zoneID, "example.com"): return ZoneEmpty, nil, nil case strings.Contains(zoneID, "mock.test"): return ZoneEmpty2, nil, nil case strings.Contains(zoneID, "long.domainname.example.com"): return ZoneEmptyLong, nil, nil } return pgo.Zone{}, nil, nil } func (c *PDNSAPIClientStubEmptyZones) PatchZone(_ string, zoneStruct pgo.Zone) (*http.Response, error) { c.patchedZones = append(c.patchedZones, zoneStruct) return &http.Response{}, nil } /******************************************************************************/ // API that returns error on PatchZone() type PDNSAPIClientStubPatchZoneFailure struct { // Anonymous struct for composition PDNSAPIClientStubEmptyZones } // Just overwrite the PatchZone method to introduce a failure func (c *PDNSAPIClientStubPatchZoneFailure) PatchZone(_ string, _ pgo.Zone) (*http.Response, error) { return nil, provider.NewSoftErrorf("Generic PDNS Error") } /******************************************************************************/ // API that returns error on ListZone() type PDNSAPIClientStubListZoneFailure struct { // Anonymous struct for composition PDNSAPIClientStubEmptyZones } // Just overwrite the ListZone method to introduce a failure func (c *PDNSAPIClientStubListZoneFailure) ListZone(_ string) (pgo.Zone, *http.Response, error) { return pgo.Zone{}, nil, provider.NewSoftErrorf("Generic PDNS Error") } /******************************************************************************/ // API that returns error on ListZones() (Zones - plural) type PDNSAPIClientStubListZonesFailure struct { // Anonymous struct for composition PDNSAPIClientStubEmptyZones } // Just overwrite the ListZones method to introduce a failure func (c *PDNSAPIClientStubListZonesFailure) ListZones() ([]pgo.Zone, *http.Response, error) { return []pgo.Zone{}, nil, provider.NewSoftErrorf("Generic PDNS Error") } /******************************************************************************/ // API that returns zone partitions given DomainFilter(s) type PDNSAPIClientStubPartitionZones struct { // Anonymous struct for composition PDNSAPIClientStubEmptyZones } func (c *PDNSAPIClientStubPartitionZones) ListZones() ([]pgo.Zone, *http.Response, error) { return []pgo.Zone{ZoneEmpty, ZoneEmpty2, ZoneEmptySimilar}, nil, nil } func (c *PDNSAPIClientStubPartitionZones) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { switch { case strings.Contains(zoneID, "example.com"): return ZoneEmpty, nil, nil case strings.Contains(zoneID, "mock.test"): return ZoneEmpty2, nil, nil case strings.Contains(zoneID, "simexample.com"): return ZoneEmptySimilar, nil, nil } return pgo.Zone{}, nil, nil } /******************************************************************************/ // Configurable API stub that performs real domain-filter partitioning. // Use it to test the intersection logic between ListZones results and the // provider's domain filter. type PDNSAPIClientStubConfigurable struct { zones []pgo.Zone listErr error } func (c *PDNSAPIClientStubConfigurable) ListZones() ([]pgo.Zone, *http.Response, error) { if c.listErr != nil { return nil, nil, c.listErr } return c.zones, nil, nil } func (c *PDNSAPIClientStubConfigurable) ListZone(_ string) (pgo.Zone, *http.Response, error) { return pgo.Zone{}, nil, nil } func (c *PDNSAPIClientStubConfigurable) PatchZone(_ string, _ pgo.Zone) (*http.Response, error) { return &http.Response{}, nil } /******************************************************************************/ type NewPDNSProviderTestSuite struct { suite.Suite } func (suite *NewPDNSProviderTestSuite) TestPDNSProviderCreate() { _, err := newProvider( context.Background(), PDNSConfig{ Server: "http://localhost:8081", DomainFilter: endpoint.NewDomainFilter([]string{""}), }) suite.Error(err, "--pdns-api-key should be specified") _, err = newProvider( context.Background(), PDNSConfig{ Server: "http://localhost:8081", APIKey: "foo", DomainFilter: endpoint.NewDomainFilter([]string{"example.com", "example.org"}), }) suite.NoError(err, "--domain-filter should raise no error") _, err = newProvider( context.Background(), PDNSConfig{ Server: "http://localhost:8081", APIKey: "foo", DomainFilter: endpoint.NewDomainFilter([]string{""}), DryRun: true, }) suite.Error(err, "--dry-run should raise an error") // This is our "regular" code path, no error should be thrown _, err = newProvider( context.Background(), PDNSConfig{ Server: "http://localhost:8081", APIKey: "foo", DomainFilter: endpoint.NewDomainFilter([]string{""}), }) suite.NoError(err, "Regular case should raise no error") } func (suite *NewPDNSProviderTestSuite) TestPDNSProviderCreateTLS() { newProvider := func(TLSConfig TLSConfig) error { _, err := newProvider( context.Background(), PDNSConfig{APIKey: "foo", TLSConfig: TLSConfig}) return err } suite.NoError(newProvider(TLSConfig{SkipTLSVerify: true}), "Disabled TLS Config should raise no error") suite.NoError(newProvider(TLSConfig{ SkipTLSVerify: true, CAFilePath: "../../internal/testresources/ca.pem", ClientCertFilePath: "../../internal/testresources/client-cert.pem", ClientCertKeyFilePath: "../../internal/testresources/client-cert-key.pem", }), "Disabled TLS Config with additional flags should raise no error") suite.NoError(newProvider(TLSConfig{}), "Enabled TLS Config without --tls-ca should raise no error") suite.NoError(newProvider(TLSConfig{ CAFilePath: "../../internal/testresources/ca.pem", }), "Enabled TLS Config with --tls-ca should raise no error") suite.Error(newProvider(TLSConfig{ CAFilePath: "../../internal/testresources/ca.pem", ClientCertFilePath: "../../internal/testresources/client-cert.pem", }), "Enabled TLS Config with --tls-client-cert only should raise an error") suite.Error(newProvider(TLSConfig{ CAFilePath: "../../internal/testresources/ca.pem", ClientCertKeyFilePath: "../../internal/testresources/client-cert-key.pem", }), "Enabled TLS Config with --tls-client-cert-key only should raise an error") suite.NoError(newProvider(TLSConfig{ CAFilePath: "../../internal/testresources/ca.pem", ClientCertFilePath: "../../internal/testresources/client-cert.pem", ClientCertKeyFilePath: "../../internal/testresources/client-cert-key.pem", }), "Enabled TLS Config with all flags should raise no error") } func (suite *NewPDNSProviderTestSuite) TestPDNSHasAliasAnnotation() { p := &PDNSProvider{} // Test endpoint without alias annotation epWithoutAlias := endpoint.NewEndpoint("test.example.com", endpoint.RecordTypeCNAME, "target.example.com") suite.False(p.hasAliasAnnotation(epWithoutAlias)) // Test endpoint with alias=false epWithAliasFalse := endpoint.NewEndpoint("test.example.com", endpoint.RecordTypeCNAME, "target.example.com") epWithAliasFalse.ProviderSpecific = endpoint.ProviderSpecific{ {Name: "alias", Value: "false"}, } suite.False(p.hasAliasAnnotation(epWithAliasFalse)) // Test endpoint with alias=true epWithAliasTrue := endpoint.NewEndpoint("test.example.com", endpoint.RecordTypeCNAME, "target.example.com") epWithAliasTrue.ProviderSpecific = endpoint.ProviderSpecific{ {Name: "alias", Value: "true"}, } suite.True(p.hasAliasAnnotation(epWithAliasTrue)) // Test endpoint with other provider specific but no alias epWithOtherPS := endpoint.NewEndpoint("test.example.com", endpoint.RecordTypeCNAME, "target.example.com") epWithOtherPS.ProviderSpecific = endpoint.ProviderSpecific{ {Name: "other", Value: "value"}, } suite.False(p.hasAliasAnnotation(epWithOtherPS)) } func (suite *NewPDNSProviderTestSuite) TestPDNSRRSetToEndpoints() { // Function definition: convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error) // Create a new provider to run tests against p := &PDNSProvider{ client: &PDNSAPIClientStub{}, } /* given an RRSet with three records, we test: - We correctly create corresponding endpoints */ eps := p.convertRRSetToEndpoints(RRSetMultipleRecords) suite.Equal(endpointsMultipleRecords, eps) /* Given an RRSet with two records, one of which is disabled, we test: - We can correctly convert the RRSet into a list of valid endpoints - We correctly discard/ignore the disabled record. */ eps = p.convertRRSetToEndpoints(RRSetDisabledRecord) suite.Equal(endpointsDisabledRecord, eps) } func (suite *NewPDNSProviderTestSuite) TestPDNSRecords() { // Function definition: Records() (endpoints []*endpoint.Endpoint, _ error) // Create a new provider to run tests against p := &PDNSProvider{ client: &PDNSAPIClientStub{}, } ctx := context.Background() /* We test that endpoints are returned correctly for a Zone when Records() is called */ eps, err := p.Records(ctx) suite.Require().NoError(err) suite.Equal(endpointsMixedRecords, eps) // Test failures are handled correctly // Create a new provider to run tests against p = &PDNSProvider{ client: &PDNSAPIClientStubListZoneFailure{}, } _, err = p.Records(ctx) suite.Error(err) suite.ErrorIs(err, provider.SoftError) p = &PDNSProvider{ client: &PDNSAPIClientStubListZonesFailure{}, } _, err = p.Records(ctx) suite.Error(err) suite.ErrorIs(err, provider.SoftError) } func (suite *NewPDNSProviderTestSuite) TestPDNSConvertEndpointsToZones() { // Function definition: ConvertEndpointsToZones(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) (zonelist []pgo.Zone, _ error) // Create a new provider to run tests against p := &PDNSProvider{ client: &PDNSAPIClientStubEmptyZones{}, } // Check inserting endpoints from a single zone zlist, err := p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist) // Check deleting endpoints from a single zone zlist, err = p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsDelete) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimpleDelete}, zlist) // Check endpoints from multiple zones #1 zlist, err = p.ConvertEndpointsToZones(endpointsMultipleZones, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch, ZoneEmptyToSimplePatch2}, zlist) // Check endpoints from multiple zones #2 zlist, err = p.ConvertEndpointsToZones(endpointsMultipleZones2, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch, ZoneEmptyToSimplePatch3}, zlist) // Check endpoints from multiple zones where some endpoints which don't exist zlist, err = p.ConvertEndpointsToZones(endpointsMultipleZonesWithNoExist, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist) // Check endpoints from a zone that does not exist zlist, err = p.ConvertEndpointsToZones(endpointsNonexistantZone, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{}, zlist) // Check endpoints that match multiple zones (one longer than other), is assigned to the right zone zlist, err = p.ConvertEndpointsToZones(endpointsLongRecord, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToLongPatch}, zlist) // Check endpoints of type CNAME, ALIAS, MX, SRV, and NS always have their values end with a trailing dot. zlist, err = p.ConvertEndpointsToZones(endpointsMixedRecords, PdnsReplace) suite.NoError(err) trailingTypes := map[string]bool{ endpoint.RecordTypeCNAME: true, "ALIAS": true, endpoint.RecordTypeMX: true, endpoint.RecordTypeSRV: true, endpoint.RecordTypeNS: true, endpoint.RecordTypePTR: true, } for _, z := range zlist { for _, rs := range z.Rrsets { if trailingTypes[rs.Type_] { for _, r := range rs.Records { suite.Equal(uint8(0x2e), r.Content[len(r.Content)-1]) } } } } // Check endpoints of type CNAME are converted to ALIAS on the domain apex zlist, err = p.ConvertEndpointsToZones(endpointsApexRecords, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToApexPatch}, zlist) // Check endpoints of type CNAME remain CNAME when no alias annotation is set zlist, err = p.ConvertEndpointsToZones(endpointsPreferAlias, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToCNAMEPatch}, zlist) // Check endpoints with alias annotation are converted to ALIAS // Note: The --prefer-alias flag now works via PostProcessor wrapper which sets the alias annotation zlist, err = p.ConvertEndpointsToZones([]*endpoint.Endpoint{endpointWithAliasAnnotation}, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToPreferAliasPatch}, zlist) } func (suite *NewPDNSProviderTestSuite) TestPDNSConvertEndpointsToZonesPartitionZones() { // Test DomainFilters p := &PDNSProvider{ client: &PDNSAPIClientStubPartitionZones{}, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } // Check inserting endpoints from a single zone which is specified in DomainFilter zlist, err := p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsReplace) suite.Require().NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist) // Check deleting endpoints from a single zone which is specified in DomainFilter zlist, err = p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsDelete) suite.Require().NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimpleDelete}, zlist) // Check endpoints from multiple zones # which one is specified in DomainFilter and one is not zlist, err = p.ConvertEndpointsToZones(endpointsMultipleZones, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist) // Check endpoints from multiple zones where some endpoints which don't exist and one that does // and is part of DomainFilter zlist, err = p.ConvertEndpointsToZones(endpointsMultipleZonesWithNoExist, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist) // Check endpoints from a zone that does not exist zlist, err = p.ConvertEndpointsToZones(endpointsNonexistantZone, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{}, zlist) // Check endpoints that match multiple zones (one longer than other), is assigned to the right zone when the longer // zone is not part of the DomainFilter zlist, err = p.ConvertEndpointsToZones(endpointsMultipleZonesWithLongRecordNotInDomainFilter, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatchLongRecordIgnoredInDomainFilter}, zlist) // Check endpoints that match multiple zones (one longer than other and one is very similar) // is assigned to the right zone when the similar zone is not part of the DomainFilter zlist, err = p.ConvertEndpointsToZones(endpointsMultipleZonesWithSimilarRecordNotInDomainFilter, PdnsReplace) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, zlist) } func (suite *NewPDNSProviderTestSuite) TestPDNSmutateRecords() { // Function definition: mutateRecords(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) error // Create a new provider to run tests against c := &PDNSAPIClientStubEmptyZones{} p := &PDNSProvider{ client: c, } // Check inserting endpoints from a single zone err := p.mutateRecords(endpointsSimpleRecord, pdnsChangeType("REPLACE")) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimplePatch}, c.patchedZones) // Reset the "patchedZones" c.patchedZones = []pgo.Zone{} // Check deleting endpoints from a single zone err = p.mutateRecords(endpointsSimpleRecord, pdnsChangeType("DELETE")) suite.NoError(err) suite.Equal([]pgo.Zone{ZoneEmptyToSimpleDelete}, c.patchedZones) // Check we fail correctly when patching fails for whatever reason p = &PDNSProvider{ client: &PDNSAPIClientStubPatchZoneFailure{}, } // Check inserting endpoints from a single zone err = p.mutateRecords(endpointsSimpleRecord, pdnsChangeType("REPLACE")) suite.Error(err) suite.ErrorIs(err, provider.SoftError) } func (suite *NewPDNSProviderTestSuite) TestPDNSClientPartitionZones() { zoneList := []pgo.Zone{ ZoneEmpty, ZoneEmpty2, } partitionResultFilteredEmptyFilter := []pgo.Zone{ ZoneEmpty, ZoneEmpty2, } partitionResultResidualEmptyFilter := ([]pgo.Zone)(nil) partitionResultFilteredSingleFilter := []pgo.Zone{ ZoneEmpty, } partitionResultResidualSingleFilter := []pgo.Zone{ ZoneEmpty2, } partitionResultFilteredMultipleFilter := []pgo.Zone{ ZoneEmpty, } partitionResultResidualMultipleFilter := []pgo.Zone{ ZoneEmpty2, } // Check filtered, residual zones when no domain filter specified filteredZones, residualZones := partitionZones(zoneList, DomainFilterListEmpty) suite.Equal(partitionResultFilteredEmptyFilter, filteredZones) suite.Equal(partitionResultResidualEmptyFilter, residualZones) // Check filtered, residual zones when a single domain filter specified filteredZones, residualZones = partitionZones(zoneList, DomainFilterListSingle) suite.Equal(partitionResultFilteredSingleFilter, filteredZones) suite.Equal(partitionResultResidualSingleFilter, residualZones) // Check filtered, residual zones when a multiple domain filter specified filteredZones, residualZones = partitionZones(zoneList, DomainFilterListMultiple) suite.Equal(partitionResultFilteredMultipleFilter, filteredZones) suite.Equal(partitionResultResidualMultipleFilter, residualZones) filteredZones, residualZones = partitionZones(zoneList, RegexDomainFilter) suite.Equal(partitionResultFilteredSingleFilter, filteredZones) suite.Equal(partitionResultResidualSingleFilter, residualZones) } // Validate whether invalid endpoints are removed by AdjustEndpoints func (suite *NewPDNSProviderTestSuite) TestPDNSAdjustEndpoints() { // Function definition: AdjustEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint // Create a new provider to run tests against p := &PDNSProvider{} tests := []struct { description string endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { description: "Valid MX endpoint is not removed", endpoints: endpointsMXRecord, expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "10 example.com"), }, }, { description: "Invalid MX endpoint with too many arguments is removed", endpoints: endpointsMXRecordInvalidFormatTooManyArgs, expected: []*endpoint.Endpoint([]*endpoint.Endpoint(nil)), }, { description: "Invalid MX endpoint is removed among valid endpoints", endpoints: endpointsMultipleMXRecordsWithSingleInvalid, expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(300), "20 backup.example.com"), }, }, { description: "Multiple invalid MX endpoints are removed", endpoints: endpointsMultipleInvalidMXRecords, expected: []*endpoint.Endpoint([]*endpoint.Endpoint(nil)), }, } for _, tt := range tests { actual, err := p.AdjustEndpoints(tt.endpoints) suite.NoError(err) suite.Equal(tt.expected, actual) } } func (suite *NewPDNSProviderTestSuite) TestPDNSGetDomainFilter() { allZones := []pgo.Zone{ZoneEmpty, ZoneEmptyLong, ZoneEmpty2} // example.com., long.domainname.example.com., mock.test. tests := []struct { name string client PDNSAPIProvider domainFilter *endpoint.DomainFilter // domains we expect the returned filter to match shouldMatch []string // domains we expect the returned filter NOT to match shouldNotMatch []string }{ { name: "no domain filter — all zones from API are in scope", client: &PDNSAPIClientStubConfigurable{ zones: allZones, }, domainFilter: nil, shouldMatch: []string{"example.com", "long.domainname.example.com", "mock.test", "sub.example.com", "sub.mock.test"}, shouldNotMatch: []string{"other.com"}, }, { name: "domain filter set — all API zones still returned (controller handles intersection with --domain-filter)", client: &PDNSAPIClientStubConfigurable{ zones: allZones, }, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), // GetDomainFilter returns all API zones, not the filtered subset; // the controller intersects with --domain-filter on its own shouldMatch: []string{"example.com", "long.domainname.example.com", "mock.test", "sub.example.com", "sub.mock.test"}, shouldNotMatch: []string{"other.com"}, }, { name: "domain filter excludes all API zones — all zones still returned (no silent fail-open)", client: &PDNSAPIClientStubConfigurable{ zones: allZones, }, domainFilter: endpoint.NewDomainFilter([]string{"notexist.org"}), // All provider-managed zones are returned; when the controller // intersects with --domain-filter=notexist.org, nothing matches // and the plan is safely empty shouldMatch: []string{"example.com", "mock.test", "long.domainname.example.com"}, shouldNotMatch: []string{"notexist.org", "other.com"}, }, { name: "ListZones error — returns empty filter (fail-open)", client: &PDNSAPIClientStubConfigurable{ listErr: provider.NewSoftErrorf("API unreachable"), }, domainFilter: nil, // empty DomainFilter matches everything shouldMatch: []string{"anything.com", "example.com"}, shouldNotMatch: []string{}, }, { name: "API returns single zone — that zone is returned regardless of domain filter", client: &PDNSAPIClientStubConfigurable{ zones: []pgo.Zone{ZoneEmpty}, // only example.com. }, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), shouldMatch: []string{"example.com", "sub.example.com"}, shouldNotMatch: []string{"mock.test", "other.com"}, }, } for _, tt := range tests { suite.Run(tt.name, func() { p := &PDNSProvider{ client: tt.client, domainFilter: tt.domainFilter, } df := p.GetDomainFilter() for _, domain := range tt.shouldMatch { suite.True(df.Match(domain), "expected filter to match %q", domain) } for _, domain := range tt.shouldNotMatch { suite.False(df.Match(domain), "expected filter NOT to match %q", domain) } }) } } func TestNewPDNSProviderTestSuite(t *testing.T) { suite.Run(t, new(NewPDNSProviderTestSuite)) } // TestPDNSPartitionZonesRegexBehavior compares two regex forms for --domain-filter // and shows how the choice of regex affects zone partitioning correctness. func TestPDNSPartitionZonesRegexBehavior(t *testing.T) { newZone := func(name string) pgo.Zone { return pgo.Zone{Id: name, Name: name, Type_: "Zone", Kind: "Native", Rrsets: []pgo.RrSet{}} } zoneNames := func(zz []pgo.Zone) []string { names := make([]string, len(zz)) for i, z := range zz { names[i] = z.Name } return names } tests := []struct { name string zones []pgo.Zone regex string regexExclude string assertions func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) }{ { // Worst case: no subdomain zone exists at all. // Both apex zones fail the regex → filtered is empty → // ConvertEndpointsToZones logs "Ignoring Endpoint" for every record. // // "example.com" → no label prefix → residual ← BUG // "other.com" → no match at all → residual ← BUG name: "complete wipeout: subdomain-only regex with only apex zones leaves filtered empty", zones: []pgo.Zone{ newZone("example.com."), newZone("other.com."), }, regex: `^[\w-]+\.example\.com$`, assertions: func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) { assert.Empty(t, filtered, "no zone matches the subdomain-only regex — every record will be ignored") assert.ElementsMatch(t, []string{"example.com.", "other.com."}, zoneNames(residual), "both zones land in residual: records in example.com. silently dropped") }, }, { // Partial match: a sub-zone happens to exist, so sub.example.com. is // managed but the apex example.com. and the deep zone are still lost. // // "example.com" → no label at all → residual ← BUG // "sub.example.com" → one label "sub" → filtered // "long.domainname.example.com" → [\w-]+ can't span dots → residual ← BUG // "simexample.com" → no .example.com suffix → residual ✓ // "mock.test" → no match → residual ✓ name: "partial match: subdomain-only regex misses apex and multi-label zones", zones: []pgo.Zone{ newZone("example.com."), newZone("sub.example.com."), newZone("long.domainname.example.com."), newZone("simexample.com."), newZone("mock.test."), }, regex: `^[\w-]+\.example\.com$`, assertions: func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) { assert.Equal(t, []string{"sub.example.com."}, zoneNames(filtered), "only the single-label subdomain zone matches") assert.Contains(t, zoneNames(residual), "example.com.", "zone apex lands in residual: its records would be ignored") assert.Contains(t, zoneNames(residual), "long.domainname.example.com.", "multi-label zone lands in residual: [\\w-]+ cannot span dots") assert.Contains(t, zoneNames(residual), "simexample.com.") assert.Contains(t, zoneNames(residual), "mock.test.") }, }, { // Exclusion regex takes priority: zones matching regexExclusion are // rejected before the inclusion regex is checked. // // "example.com" → inclusion matches, exclusion does not → filtered ✓ // "staging.example.com" → inclusion matches, exclusion matches → residual ✓ // "prod.example.com" → inclusion matches, exclusion does not → filtered ✓ // "mock.test" → inclusion does not match → residual ✓ name: "exclusion regex overrides inclusion: staging zones are excluded", regexExclude: `^staging\.`, zones: []pgo.Zone{ newZone("example.com."), newZone("staging.example.com."), newZone("prod.example.com."), newZone("mock.test."), }, regex: `^([\w-]+\.)*example\.com$`, assertions: func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) { assert.ElementsMatch(t, []string{"example.com.", "prod.example.com."}, zoneNames(filtered), "only non-excluded example.com zones must be filtered") assert.ElementsMatch(t, []string{"staging.example.com.", "mock.test."}, zoneNames(residual), "staging zone is excluded by regexExclusion; mock.test does not match inclusion") }, }, { // ([\w-]+\.)* with zero repetitions matches the apex; one or more // repetitions match subdomain zones at any depth. // Suffix similarity (simexample.com) is rejected by the dot-boundary. // // "example.com" → 0 repetitions → filtered ✓ // "sub.example.com" → 1 repetition "sub." → filtered ✓ // "long.domainname.example.com" → 2 repetitions → filtered ✓ // "simexample.com" → no dot-boundary match → residual ✓ // "mock.test" → no match → residual ✓ name: "zone-aware regex (* quantifier) matches apex and all subdomain depths", zones: []pgo.Zone{ newZone("example.com."), newZone("sub.example.com."), newZone("long.domainname.example.com."), newZone("simexample.com."), newZone("mock.test."), }, regex: `^([\w-]+\.)*example\.com$`, assertions: func(t *testing.T, filtered []pgo.Zone, residual []pgo.Zone) { assert.ElementsMatch(t, []string{"example.com.", "sub.example.com.", "long.domainname.example.com."}, zoneNames(filtered), "apex and all subdomain zones must be filtered") assert.ElementsMatch(t, []string{"simexample.com.", "mock.test."}, zoneNames(residual), "only truly unrelated zones must be residual") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var exclusion *regexp.Regexp if tt.regexExclude != "" { exclusion = regexp.MustCompile(tt.regexExclude) } df := endpoint.NewRegexDomainFilter(regexp.MustCompile(tt.regex), exclusion) filtered, residual := partitionZones(tt.zones, df) tt.assertions(t, filtered, residual) }) } } ================================================ FILE: provider/pihole/client.go ================================================ /* Copyright 2017 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 pihole import ( "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/cookiejar" "net/url" "strings" log "github.com/sirupsen/logrus" "golang.org/x/net/html" extdnshttp "sigs.k8s.io/external-dns/pkg/http" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/provider" ) // piholeAPI declares the "API" actions performed against the Pihole server. type piholeAPI interface { // listRecords returns endpoints for the given record type (A or CNAME). listRecords(ctx context.Context, rtype string) ([]*endpoint.Endpoint, error) // createRecord will create a new record for the given endpoint. createRecord(ctx context.Context, ep *endpoint.Endpoint) error // deleteRecord will delete the given record. deleteRecord(ctx context.Context, ep *endpoint.Endpoint) error } // piholeClient implements the piholeAPI. type piholeClient struct { cfg PiholeConfig httpClient *http.Client token string } // newPiholeClient creates a new Pihole API client. func newPiholeClient(cfg PiholeConfig) (piholeAPI, error) { if cfg.Server == "" { return nil, ErrNoPiholeServer } // Setup a persistent cookiejar for storing PHP session information // This call will never return an error jar, _ := cookiejar.New(&cookiejar.Options{}) // Setup an HTTP client using the cookiejar httpClient := &http.Client{ Jar: jar, Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: cfg.TLSInsecureSkipVerify, }, }, } cl := extdnshttp.NewInstrumentedClient(httpClient) p := &piholeClient{ cfg: cfg, httpClient: cl, } if cfg.Password != "" { if err := p.retrieveNewToken(context.Background()); err != nil { return nil, err } } return p, nil } func (p *piholeClient) listRecords(ctx context.Context, rtype string) ([]*endpoint.Endpoint, error) { form := &url.Values{} form.Add("action", "get") if p.token != "" { form.Add("token", p.token) } url, err := p.urlForRecordType(rtype) if err != nil { return nil, err } log.Debugf("Listing %s records from %s", rtype, url) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Add("content-type", "application/x-www-form-urlencoded") body, err := p.do(req) if err != nil { return nil, err } defer body.Close() raw, err := io.ReadAll(body) if err != nil { return nil, err } // Response is a map of "data" to a list of lists where the first element in each // list is the dns name and the second is the target. // Pi-Hole does not allow for a record to have multiple targets. var res map[string][][]string if err := json.Unmarshal(raw, &res); err != nil { // Unfortunately this could also just mean we needed to authenticate (still returns a 200). // Thankfully the body is a short and concise error. err = errors.New(string(raw)) if strings.Contains(err.Error(), "expired") && p.cfg.Password != "" { // Try to fetch a new token and redo the request. // Full error message at time of writing: // "Not allowed (login session invalid or expired, please relogin on the Pi-hole dashboard)!" log.Info("Pihole token has expired, fetching a new one") if err := p.retrieveNewToken(ctx); err != nil { return nil, err } return p.listRecords(ctx, rtype) } // Return raw body as error. return nil, err } out := make([]*endpoint.Endpoint, 0) data, ok := res["data"] if !ok { return out, nil } loop: for _, rec := range data { name := rec[0] target := rec[1] if !p.cfg.DomainFilter.Match(name) { log.Debugf("Skipping %s that does not match domain filter", name) continue } switch rtype { case endpoint.RecordTypeA: if strings.Contains(target, ":") { continue loop } case endpoint.RecordTypeAAAA: if strings.Contains(target, ".") { continue loop } } out = append(out, &endpoint.Endpoint{ DNSName: name, Targets: []string{target}, RecordType: rtype, }) } return out, nil } func (p *piholeClient) createRecord(ctx context.Context, ep *endpoint.Endpoint) error { return p.apply(ctx, "add", ep) } func (p *piholeClient) deleteRecord(ctx context.Context, ep *endpoint.Endpoint) error { return p.apply(ctx, "delete", ep) } func (p *piholeClient) aRecordsScript() string { return fmt.Sprintf("%s/admin/scripts/pi-hole/php/customdns.php", p.cfg.Server) } func (p *piholeClient) cnameRecordsScript() string { return fmt.Sprintf("%s/admin/scripts/pi-hole/php/customcname.php", p.cfg.Server) } func (p *piholeClient) urlForRecordType(rtype string) (string, error) { switch rtype { case endpoint.RecordTypeA, endpoint.RecordTypeAAAA: return p.aRecordsScript(), nil case endpoint.RecordTypeCNAME: return p.cnameRecordsScript(), nil default: return "", fmt.Errorf("unsupported record type: %s", rtype) } } type actionResponse struct { Success bool `json:"success"` Message string `json:"message"` } func (p *piholeClient) apply(ctx context.Context, action string, ep *endpoint.Endpoint) error { if !p.cfg.DomainFilter.Match(ep.DNSName) { log.Debugf("Skipping %s %s that does not match domain filter", action, ep.DNSName) return nil } url, err := p.urlForRecordType(ep.RecordType) if err != nil { log.Warnf("Skipping unsupported endpoint %s %s %v", ep.DNSName, ep.RecordType, ep.Targets) return nil } if p.cfg.DryRun { log.Infof("DRY RUN: %s %s IN %s -> %s", action, ep.DNSName, ep.RecordType, ep.Targets[0]) return nil } log.Infof("%s %s IN %s -> %s", action, ep.DNSName, ep.RecordType, ep.Targets[0]) form := p.newDNSActionForm(action, ep) if strings.Contains(ep.DNSName, "*") { return provider.NewSoftError(errors.New("UNSUPPORTED: Pihole DNS names cannot return wildcard")) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Add("content-type", "application/x-www-form-urlencoded") body, err := p.do(req) if err != nil { return err } defer body.Close() raw, err := io.ReadAll(body) if err != nil { return nil } var res actionResponse if err := json.Unmarshal(raw, &res); err != nil { // Unfortunately this could also be a generic server or auth error. err = errors.New(string(raw)) if strings.Contains(err.Error(), "expired") && p.cfg.Password != "" { // Try to fetch a new token and redo the request. log.Info("Pihole token has expired, fetching a new one") if err := p.retrieveNewToken(ctx); err != nil { return err } return p.apply(ctx, action, ep) } // Return raw body as error. return err } if !res.Success { return errors.New(res.Message) } return nil } func (p *piholeClient) retrieveNewToken(ctx context.Context) error { if p.cfg.Password == "" { return nil } form := &url.Values{} form.Add("pw", p.cfg.Password) url := fmt.Sprintf("%s/admin/index.php?login", p.cfg.Server) log.Debugf("Fetching new token from %s", url) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Add("content-type", "application/x-www-form-urlencoded") body, err := p.do(req) if err != nil { return err } defer body.Close() // If successful the request will redirect us to an HTML page with a hidden // div containing the token...The token gives us access to other PHP // endpoints via a form value. p.token, err = parseTokenFromLogin(body) return err } func (p *piholeClient) newDNSActionForm(action string, ep *endpoint.Endpoint) *url.Values { form := &url.Values{} form.Add("action", action) form.Add("domain", ep.DNSName) switch ep.RecordType { case endpoint.RecordTypeA, endpoint.RecordTypeAAAA: form.Add("ip", ep.Targets[0]) case endpoint.RecordTypeCNAME: form.Add("target", ep.Targets[0]) } if p.token != "" { form.Add("token", p.token) } return form } func (p *piholeClient) do(req *http.Request) (io.ReadCloser, error) { res, err := p.httpClient.Do(req) if err != nil { return nil, err } if res.StatusCode != http.StatusOK { defer res.Body.Close() return nil, fmt.Errorf("received non-200 status code from request: %s", res.Status) } return res.Body, nil } func parseTokenFromLogin(body io.ReadCloser) (string, error) { doc, err := html.Parse(body) if err != nil { return "", err } tokenNode := getElementById(doc, "token") if tokenNode == nil { return "", errors.New("could not parse token from login response") } return tokenNode.FirstChild.Data, nil } func getAttribute(n *html.Node, key string) (string, bool) { for _, attr := range n.Attr { if attr.Key == key { return attr.Val, true } } return "", false } func hasID(n *html.Node, id string) bool { if n.Type == html.ElementNode { s, ok := getAttribute(n, "id") if ok && s == id { return true } } return false } func traverse(n *html.Node, id string) *html.Node { if hasID(n, id) { return n } for c := n.FirstChild; c != nil; c = c.NextSibling { result := traverse(c, id) if result != nil { return result } } return nil } func getElementById(n *html.Node, id string) *html.Node { return traverse(n, id) } ================================================ FILE: provider/pihole/clientV6.go ================================================ /* 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 pihole import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" log "github.com/sirupsen/logrus" extdnshttp "sigs.k8s.io/external-dns/pkg/http" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/provider" ) const ( contentTypeJSON = "application/json" apiAuthPath = "/api/auth" apiConfigDNS = "/api/config/dns" ) // piholeClient implements the piholeAPI. type piholeClientV6 struct { cfg PiholeConfig httpClient *http.Client token string } // newPiholeClient creates a new Pihole API V6 client. func newPiholeClientV6(cfg PiholeConfig) (piholeAPI, error) { if cfg.Server == "" { return nil, ErrNoPiholeServer } // Setup an HTTP client httpClient := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: cfg.TLSInsecureSkipVerify, }, }, } cl := extdnshttp.NewInstrumentedClient(httpClient) p := &piholeClientV6{ cfg: cfg, httpClient: cl, } if cfg.Password != "" { if err := p.retrieveNewToken(context.Background()); err != nil { return nil, err } } return p, nil } func (p *piholeClientV6) getConfigValue(ctx context.Context, rtype string) ([]string, error) { apiUrl, err := p.urlForRecordType(rtype) if err != nil { return nil, err } log.Debugf("Listing %s records from %s", rtype, apiUrl) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil) if err != nil { return nil, err } jRes, err := p.do(req) if err != nil { return nil, err } // Parse JSON response var apiResponse ApiRecordsResponse if err := json.Unmarshal(jRes, &apiResponse); err != nil { return nil, fmt.Errorf("failed to unmarshal error response: %w", err) } // Pi-Hole does not allow for a record to have multiple targets. var results []string if endpoint.RecordTypeCNAME == rtype { results = apiResponse.Config.DNS.CnameRecords } else { results = apiResponse.Config.DNS.Hosts } return results, nil } func (p *piholeClientV6) listRecords(ctx context.Context, rtype string) ([]*endpoint.Endpoint, error) { results, err := p.getConfigValue(ctx, rtype) if err != nil { return nil, err } endpoints := make(map[string]*endpoint.Endpoint) for _, rec := range results { recs := strings.FieldsFunc(rec, func(r rune) bool { return r == ' ' || r == ',' }) if len(recs) < 2 { log.Warnf("skipping record %s: invalid format received from PiHole", rec) continue } var DNSName, Target string var Ttl = endpoint.TTL(0) // A/AAAA record format is target(IP) DNSName DNSName, Target = recs[1], recs[0] switch rtype { case endpoint.RecordTypeA: // PiHole return A and AAAA records. Filter to only keep the A records if endpoint.SuitableType(Target) != endpoint.RecordTypeA { continue } case endpoint.RecordTypeAAAA: // PiHole return A and AAAA records. Filter to only keep the AAAA records if endpoint.SuitableType(Target) != endpoint.RecordTypeAAAA { continue } case endpoint.RecordTypeCNAME: // PiHole return only CNAME records. // CNAME format is DNSName,target, ttl? DNSName, Target = recs[0], recs[1] if len(recs) == 3 { // TTL is present // Parse string to int64 first if ttlInt, err := strconv.ParseInt(recs[2], 10, 64); err == nil { Ttl = endpoint.TTL(ttlInt) } else { log.Warnf("failed to parse TTL value received from PiHole '%s': %v; using a TTL of %d", recs[2], err, Ttl) } } } ep := endpoint.NewEndpointWithTTL(DNSName, rtype, Ttl, Target) if oldEp, ok := endpoints[DNSName]; ok { ep.Targets = append(oldEp.Targets, Target) // nolint: gocritic // appendAssign } endpoints[DNSName] = ep } out := make([]*endpoint.Endpoint, 0, len(endpoints)) for _, ep := range endpoints { out = append(out, ep) } return out, nil } func (p *piholeClientV6) createRecord(ctx context.Context, ep *endpoint.Endpoint) error { return p.apply(ctx, http.MethodPut, ep) } func (p *piholeClientV6) deleteRecord(ctx context.Context, ep *endpoint.Endpoint) error { return p.apply(ctx, http.MethodDelete, ep) } func (p *piholeClientV6) aRecordsScript() string { return fmt.Sprintf("%s"+apiConfigDNS+"/hosts", p.cfg.Server) } func (p *piholeClientV6) cnameRecordsScript() string { return fmt.Sprintf("%s"+apiConfigDNS+"/cnameRecords", p.cfg.Server) } func (p *piholeClientV6) urlForRecordType(rtype string) (string, error) { switch rtype { case endpoint.RecordTypeA, endpoint.RecordTypeAAAA: return p.aRecordsScript(), nil case endpoint.RecordTypeCNAME: return p.cnameRecordsScript(), nil default: return "", fmt.Errorf("unsupported record type: %s", rtype) } } // ApiAuthResponse Define a struct to match the JSON response /auth/app structure type ApiAuthResponse struct { Session struct { Valid bool `json:"valid"` TOTP bool `json:"totp"` SID string `json:"sid"` CSRF string `json:"csrf"` Validity int `json:"validity"` Message string `json:"message"` } `json:"session"` Took float64 `json:"took"` } // ApiErrorResponse Define struct to match the JSON structure type ApiErrorResponse struct { Error struct { Key string `json:"key"` Message string `json:"message"` Hint string `json:"hint"` } `json:"error"` Took float64 `json:"took"` } // ApiRecordsResponse Define struct to match JSON structure type ApiRecordsResponse struct { Config struct { DNS struct { Hosts []string `json:"hosts"` CnameRecords []string `json:"cnameRecords"` } `json:"dns"` } `json:"config"` Took float64 `json:"took"` } func (p *piholeClientV6) generateApiUrl(baseUrl, params string) string { return fmt.Sprintf("%s/%s", baseUrl, url.PathEscape(params)) } func (p *piholeClientV6) apply(ctx context.Context, action string, ep *endpoint.Endpoint) error { if !p.cfg.DomainFilter.Match(ep.DNSName) { log.Debugf("Skipping : %s %s that does not match domain filter", action, ep.DNSName) return nil } apiUrl, err := p.urlForRecordType(ep.RecordType) if err != nil { log.Warnf("Skipping : unsupported endpoint %s %s %v", ep.DNSName, ep.RecordType, ep.Targets) return nil } if len(ep.Targets) == 0 { log.Infof("Skipping : missing targets %s %s %s", action, ep.DNSName, ep.RecordType) return nil } // Get the current record if strings.Contains(ep.DNSName, "*") { return provider.NewSoftError(errors.New("UNSUPPORTED: Pihole DNS names cannot return wildcard")) } if ep.RecordType == endpoint.RecordTypeCNAME && len(ep.Targets) > 1 { return provider.NewSoftError(errors.New("UNSUPPORTED: Pihole CNAME records cannot have multiple targets")) } for _, target := range ep.Targets { if p.cfg.DryRun { log.Infof("DRY RUN: %s %s IN %s -> %s", action, ep.DNSName, ep.RecordType, target) continue } log.Infof("%s %s IN %s -> %s", action, ep.DNSName, ep.RecordType, target) targetApiUrl := apiUrl switch ep.RecordType { case endpoint.RecordTypeA, endpoint.RecordTypeAAAA: targetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf("%s %s", target, ep.DNSName)) case endpoint.RecordTypeCNAME: if ep.RecordTTL.IsConfigured() { targetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf("%s,%s,%d", ep.DNSName, target, ep.RecordTTL)) } else { targetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf("%s,%s", ep.DNSName, target)) } } req, err := http.NewRequestWithContext(ctx, action, targetApiUrl, nil) if err != nil { return err } _, err = p.do(req) if err != nil { return err } } return nil } func (p *piholeClientV6) retrieveNewToken(ctx context.Context) error { if p.cfg.Password == "" { return nil } apiUrl := fmt.Sprintf("%s"+apiAuthPath, p.cfg.Server) log.Debugf("Fetching new token from %s", apiUrl) // Define the JSON payload jsonData := []byte(`{"password":"` + p.cfg.Password + `"}`) req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiUrl, bytes.NewBuffer(jsonData)) if err != nil { return err } jRes, err := p.do(req) if err != nil { return err } // Parse JSON response var apiResponse ApiAuthResponse if err := json.Unmarshal(jRes, &apiResponse); err != nil { log.Errorf("Auth Query : failed to unmarshal error response: %v", err) } else if apiResponse.Session.SID != "" { // Set the token p.token = apiResponse.Session.SID } return err } func (p *piholeClientV6) checkTokenValidity(ctx context.Context) (bool, error) { if p.token == "" { return false, nil } apiUrl := fmt.Sprintf("%s"+apiAuthPath, p.cfg.Server) req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiUrl, nil) if err != nil { return false, nil } req.Header.Add("content-type", contentTypeJSON) if p.token != "" { req.Header.Add("X-FTL-SID", p.token) } res, err := p.httpClient.Do(req) if err != nil { return false, err } jRes, err := io.ReadAll(res.Body) defer res.Body.Close() if err != nil { return false, err } // Parse JSON response var apiResponse ApiAuthResponse if err := json.Unmarshal(jRes, &apiResponse); err != nil { return false, fmt.Errorf("failed to unmarshal error response: %w", err) } return apiResponse.Session.Valid, nil } func (p *piholeClientV6) do(req *http.Request) ([]byte, error) { req.Header.Add("content-type", contentTypeJSON) if p.token != "" { req.Header.Add("X-FTL-SID", p.token) } res, err := p.httpClient.Do(req) if err != nil { return nil, err } jRes, err := io.ReadAll(res.Body) defer res.Body.Close() if err != nil { return nil, err } if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated && res.StatusCode != http.StatusNoContent { // Parse JSON response var apiError ApiErrorResponse if err := json.Unmarshal(jRes, &apiError); err != nil { return nil, fmt.Errorf("failed to unmarshal error response: %w", err) } // Ignore if the entry already exists when adding a record if strings.Contains(apiError.Error.Message, "Item already present") { return jRes, nil } // Ignore if the entry does not exist when deleting a record if res.StatusCode == http.StatusNotFound && req.Method == http.MethodDelete { return jRes, nil } if log.IsLevelEnabled(log.DebugLevel) { log.Debugf("Error on request %s", req.URL) if req.Body != nil { log.Debugf("Body of the request %s", req.Body) } } if res.StatusCode == http.StatusUnauthorized && p.token != "" { tryCount := 1 maxRetries := 3 // Try to fetch a new token and redo the request. for tryCount <= maxRetries { valid, err := p.checkTokenValidity(req.Context()) if err != nil { return nil, err } if !valid { log.Debugf("Pihole token has expired, fetching a new one. Try (%d/%d)", tryCount, maxRetries) if err := p.retrieveNewToken(req.Context()); err != nil { return nil, err } tryCount++ continue } break } if tryCount > maxRetries { return nil, errors.New("max tries reached for token renewal") } return p.do(req) } return nil, fmt.Errorf("received %d status code from request: [%s] %s (%s) - %fs", res.StatusCode, apiError.Error.Key, apiError.Error.Message, apiError.Error.Hint, apiError.Took) } return jRes, nil } ================================================ FILE: provider/pihole/clientV6_test.go ================================================ /* 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 pihole import ( "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "strings" "testing" "github.com/google/go-cmp/cmp" "sigs.k8s.io/external-dns/endpoint" ) func newTestServerV6(t *testing.T, hdlr http.HandlerFunc) *httptest.Server { t.Helper() svr := httptest.NewServer(hdlr) return svr } type errorTransportV6 struct{} func (t *errorTransportV6) RoundTrip(_ *http.Request) (*http.Response, error) { return nil, errors.New("network error") } func TestNewPiholeClientV6(t *testing.T) { // Test correct error on no server provided _, err := newPiholeClientV6(PiholeConfig{APIVersion: "6"}) if err == nil { t.Error("Expected error from config with no server") } else if !errors.Is(err, ErrNoPiholeServer) { t.Error("Expected ErrNoPiholeServer, got", err) } // Test new client with no password. Should create the client cleanly. cl, err := newPiholeClientV6(PiholeConfig{ Server: "test", APIVersion: "6", }) if err != nil { t.Fatal(err) } if _, ok := cl.(*piholeClientV6); !ok { t.Error("Did not create a new pihole client") } // Create a test server srvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/auth" && r.Method == http.MethodPost { var requestData map[string]string err := json.NewDecoder(r.Body).Decode(&requestData) if err != nil { t.Fatal(err) } defer r.Body.Close() w.Header().Set("Content-Type", "application/json") if requestData["password"] != "correct" { // Return unsuccessful authentication response w.WriteHeader(http.StatusUnauthorized) _, err = w.Write([]byte(`{ "session": { "valid": false, "totp": false, "sid": null, "validity": -1, "message": "password incorrect" }, "took": 0.2 }`)) if err != nil { t.Fatal(err) } return } // Return successful authentication response _, err = w.Write([]byte(`{ "session": { "valid": true, "totp": false, "sid": "supersecret", "csrf": "csrfvalue", "validity": 1800, "message": "password correct" }, "took": 0.18 }`)) } else { http.NotFound(w, r) } }) defer srvr.Close() // Test invalid password _, err = newPiholeClientV6( PiholeConfig{Server: srvr.URL, APIVersion: "6", Password: "wrong"}, ) if err == nil { t.Error("Expected error for creating client with invalid password") } // Test correct password cl, err = newPiholeClientV6( PiholeConfig{Server: srvr.URL, APIVersion: "6", Password: "correct"}, ) if err != nil { t.Fatal(err) } if cl.(*piholeClientV6).token != "supersecret" { t.Error("Parsed invalid token from login response:", cl.(*piholeClient).token) } } func TestListRecordsV6(t *testing.T) { // Create a test server srvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // Return A records if _, err := w.Write([]byte(`{ "config": { "dns": { "hosts": [ "192.168.178.33 service1.example.com", "192.168.178.34 service2.example.com", "192.168.178.34 service3.example.com", "192.168.178.35 service8.example.com", "192.168.178.36 service8.example.com", "fc00::1:192:168:1:1 service4.example.com", "fc00::1:192:168:1:2 service5.example.com", "fc00::1:192:168:1:3 service6.example.com", "::ffff:192.168.20.3 service7.example.com", "fc00::1:192:168:1:4 service9.example.com", "fc00::1:192:168:1:5 service9.example.com", "192.168.20.3 service7.example.com" ] } }, "took": 5 }`)); err != nil { t.Fatal(err) } case r.URL.Path == "/api/config/dns/cnameRecords" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // Return A records w.Write([]byte(`{ "config": { "dns": { "cnameRecords": [ "source1.example.com,target1.domain.com,1000", "source2.example.com,target2.domain.com,50", "source3.example.com,target3.domain.com" ] } }, "took": 5 }`)) default: http.NotFound(w, r) } }) defer srvr.Close() // Create a client cfg := PiholeConfig{ Server: srvr.URL, APIVersion: "6", } cl, err := newPiholeClientV6(cfg) if err != nil { t.Fatal(err) } // Ensure A records were parsed correctly expected := []*endpoint.Endpoint{ { DNSName: "service1.example.com", Targets: []string{"192.168.178.33"}, }, { DNSName: "service2.example.com", Targets: []string{"192.168.178.34"}, }, { DNSName: "service3.example.com", Targets: []string{"192.168.178.34"}, }, { DNSName: "service7.example.com", Targets: []string{"192.168.20.3"}, }, { DNSName: "service8.example.com", Targets: []string{"192.168.178.35", "192.168.178.36"}, }, } // Test retrieve A records unfiltered arecs, err := cl.listRecords(t.Context(), endpoint.RecordTypeA) if err != nil { t.Fatal(err) } expectedMap := make(map[string]*endpoint.Endpoint) for _, ep := range expected { expectedMap[ep.DNSName] = ep } for _, rec := range arecs { if ep, ok := expectedMap[rec.DNSName]; ok { if cmp.Diff(ep.Targets, rec.Targets) != "" { t.Errorf("Got invalid targets for %s: %v, expected: %v", rec.DNSName, rec.Targets, ep.Targets) } } } // Ensure AAAA records were parsed correctly expected = []*endpoint.Endpoint{ { DNSName: "service4.example.com", Targets: []string{"fc00::1:192:168:1:1"}, }, { DNSName: "service5.example.com", Targets: []string{"fc00::1:192:168:1:2"}, }, { DNSName: "service6.example.com", Targets: []string{"fc00::1:192:168:1:3"}, }, { DNSName: "service7.example.com", Targets: []string{"::ffff:192.168.20.3"}, }, { DNSName: "service9.example.com", Targets: []string{"fc00::1:192:168:1:4", "fc00::1:192:168:1:5"}, }, } // Test retrieve AAAA records unfiltered arecs, err = cl.listRecords(t.Context(), endpoint.RecordTypeAAAA) if err != nil { t.Fatal(err) } if len(arecs) != len(expected) { t.Fatalf("Expected %d AAAA records returned, got: %d", len(expected), len(arecs)) } expectedMap = make(map[string]*endpoint.Endpoint) for _, ep := range expected { expectedMap[ep.DNSName] = ep } for _, rec := range arecs { if ep, ok := expectedMap[rec.DNSName]; ok { if cmp.Diff(ep.Targets, rec.Targets) != "" { t.Errorf("Got invalid targets for %s: %v, expected: %v", rec.DNSName, rec.Targets, ep.Targets) } } } // Ensure CNAME records were parsed correctly expected = []*endpoint.Endpoint{ { DNSName: "source1.example.com", Targets: []string{"target1.domain.com"}, RecordTTL: 1000, }, { DNSName: "source2.example.com", Targets: []string{"target2.domain.com"}, RecordTTL: 50, }, { DNSName: "source3.example.com", Targets: []string{"target3.domain.com"}, }, } // Test retrieve CNAME records unfiltered cnamerecs, err := cl.listRecords(t.Context(), endpoint.RecordTypeCNAME) if err != nil { t.Fatal(err) } if len(cnamerecs) != len(expected) { t.Fatalf("Expected %d CAME records returned, got: %d", len(expected), len(cnamerecs)) } expectedMap = make(map[string]*endpoint.Endpoint) for _, ep := range expected { expectedMap[ep.DNSName] = ep } for _, rec := range arecs { if ep, ok := expectedMap[rec.DNSName]; ok { if cmp.Diff(ep.Targets, rec.Targets) != "" { t.Errorf("Got invalid targets for %s: %v, expected: %v", rec.DNSName, rec.Targets, ep.Targets) } } } // Note: filtered tests are not needed since A/AAAA records are tested filtered already // and cnameRecords have their own element // unsupported type _, err = cl.listRecords(t.Context(), endpoint.RecordTypeNAPTR) if err == nil || err.Error() != fmt.Sprintf("unsupported record type: %s", endpoint.RecordTypeNAPTR) { t.Fatal("Expected error for using unsupported record type") } } func TestErrorsV6(t *testing.T) { // Error test cases // Create a client cfgErrURL := PiholeConfig{ Server: "not an url", APIVersion: "6", } clErrURL, err := newPiholeClientV6(cfgErrURL) if err != nil { t.Fatal(err) } _, err = clErrURL.listRecords(t.Context(), endpoint.RecordTypeCNAME) if err == nil { t.Fatal("Expected error for using invalid URL") } _, err = clErrURL.listRecords(nil, endpoint.RecordTypeCNAME) if err == nil { t.Fatal("Expected error for nil context") } // Unmarshalling error srvrErrJson := newTestServerV6(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // Return A records w.Write([]byte(`I am not JSON`)) }) defer srvrErrJson.Close() // Create a client cfgErr := PiholeConfig{ Server: srvrErrJson.URL, APIVersion: "6", } clErr, _ := newPiholeClientV6(cfgErr) resp, err := clErr.listRecords(t.Context(), endpoint.RecordTypeA) if err == nil { t.Fatal(err) } if !strings.HasPrefix(err.Error(), "failed to unmarshal error response:") { t.Fatal("Expected unmarshalling error, got:", err) } // bad record format return by server srvrErr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/config/dns/hosts" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // Return A records w.Write([]byte(`{ "config": { "dns": { "hosts": [ "192.168.178.33" ] } }, "took": 5 }`)) case r.URL.Path == "/api/config/dns/cnameRecords" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // Return A records w.Write([]byte(`{ "config": { "dns": { "cnameRecords": [ "source1.example.com,target1.domain.com,100", "source2.example.com,target2.domain.com,not_an_integer" ] } }, "took": 5 }`)) default: http.NotFound(w, r) } }) defer srvrErr.Close() // Create a client cfgErr = PiholeConfig{ Server: srvrErr.URL, APIVersion: "6", } clErr, _ = newPiholeClientV6(cfgErr) resp, err = clErr.listRecords(t.Context(), endpoint.RecordTypeA) if err != nil { t.Fatal(err) } if len(resp) != 0 { t.Fatal("Expected no records returned, got:", len(resp)) } resp, err = clErr.listRecords(t.Context(), endpoint.RecordTypeCNAME) if err != nil { t.Fatal(err) } if len(resp) != 2 { t.Fatal("Expected one records returned, got:", len(resp)) } expected := []*endpoint.Endpoint{ { DNSName: "source1.example.com", Targets: []string{"target1.domain.com"}, RecordTTL: 100, }, { DNSName: "source2.example.com", Targets: []string{"target2.domain.com"}, }, } expectedMap := make(map[string]*endpoint.Endpoint) for _, ep := range expected { expectedMap[ep.DNSName] = ep } for _, rec := range resp { if ep, ok := expectedMap[rec.DNSName]; ok { if cmp.Diff(ep.Targets, rec.Targets) != "" { t.Errorf("Got invalid targets for %s: %v, expected: %v", rec.DNSName, rec.Targets, ep.Targets) } if ep.RecordTTL != rec.RecordTTL { t.Errorf("Got invalid TTL for %s: %d, expected: %d", rec.DNSName, rec.RecordTTL, ep.RecordTTL) } } else { t.Errorf("Unexpected record found: %s", rec.DNSName) } } } func TestTokenValidity(t *testing.T) { srvok := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/auth" && r.Method == http.MethodGet { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // Return bad content w.Write([]byte(`{ "session": { "valid": true, "totp": false, "sid": "supersecret", "csrf": "csrfvalue", "validity": 1800, "message": "password correct" }, "took": 0.17 }`)) } }) // Create a client cfgOK := PiholeConfig{ Server: srvok.URL, APIVersion: "6", } clOK, err := newPiholeClientV6(cfgOK) clOK.(*piholeClientV6).token = "valid" validity, err := clOK.(*piholeClientV6).checkTokenValidity(t.Context()) if err != nil { t.Fatal(err) } if !validity { t.Fatal("Should be valid") } // Create a test server srvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/auth" && r.Method == http.MethodGet { w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") // Return bad content w.Write([]byte(`Not a JSON`)) } }) defer srvr.Close() // // Create a client cfg := PiholeConfig{ Server: srvr.URL, APIVersion: "6", } cl, err := newPiholeClientV6(cfg) if err != nil { t.Fatal(err) } validity, err = cl.(*piholeClientV6).checkTokenValidity(t.Context()) if err != nil { t.Fatal(err) } if validity { t.Fatal("Should be invalid : no token") } // Test token validity cl.(*piholeClientV6).token = "valid" validity, err = cl.(*piholeClientV6).checkTokenValidity(nil) if err != nil { t.Fatal(err) } if validity { t.Fatal("Should be invalid : nil context") } validity, err = cl.(*piholeClientV6).checkTokenValidity(t.Context()) if err == nil { t.Fatal("Should be invalid : failed to unmarshal error") } if !strings.HasPrefix(err.Error(), "failed to unmarshal error response") { t.Fatal("Expected unmarshalling error, got:", err) } if validity { t.Fatal("Should be invalid : unmarshalling error") } } func TestDo(t *testing.T) { srvDo := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/auth/ok" && r.Method == http.MethodGet: w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // Return bad content w.Write([]byte(`{ "session": { "valid": true, "totp": false, "sid": "supersecret", "csrf": "csrfvalue", "validity": 1800, "message": "password correct" }, "took": 0.16 }`)) case r.URL.Path == "/api/auth" && r.Method == http.MethodPost: w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // Return bad content w.Write([]byte(`{ "session": { "valid": false, "totp": false, "sid": "", "csrf": "csrfvalue", "validity": 1800, "message": "password correct" }, "took": 0.15 }`)) case r.URL.Path == "/api/auth" && r.Method == http.MethodGet: w.WriteHeader(http.StatusUnauthorized) // Return bad content w.Write([]byte(`{ "error": { "key": "401", "message": "Expired token", "hint": "Expired token" }, "took": 0.14 }`)) case r.URL.Path == "/api/auth/418" && r.Method == http.MethodGet: w.WriteHeader(http.StatusTeapot) // Return bad content w.Write([]byte(`{ "error": { "key": "418", "message": "I'm a teapot", "hint": "It is a teapot" }, "took": 0.13 }`)) case r.URL.Path == "/api/auth/nojson" && r.Method == http.MethodGet: // Return bad content w.WriteHeader(http.StatusTeapot) w.Write([]byte(`Not a JSON`)) case r.URL.Path == "/api/auth/401" && r.Method == http.MethodGet: w.WriteHeader(http.StatusUnauthorized) // Return bad content w.Write([]byte(`{ "error": { "key": "401", "message": "Expired token", "hint": "Expired token" }, "took": 0.10 }`)) } }) defer srvDo.Close() // Create a client cfg := PiholeConfig{ Server: srvDo.URL, APIVersion: "6", } cl, err := newPiholeClientV6(cfg) cl.(*piholeClientV6).token = "valid" rq, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, srvDo.URL+"/api/auth/ok", nil) resp, err := cl.(*piholeClientV6).do(rq) if err != nil { t.Fatal(err) } if len(resp) == 0 { t.Fatal("Should have a response") } // Test not handled error code rq, _ = http.NewRequestWithContext(t.Context(), http.MethodGet, srvDo.URL+"/api/auth/418", nil) resp, err = cl.(*piholeClientV6).do(rq) if resp != nil { t.Fatal(err) } if err == nil { t.Fatal("Should have an error") } if !strings.HasPrefix(err.Error(), "received 418 status code from request") { t.Fatal("Expected error for unexpected status code, got:", err) } // Test error on non JSON response rq, _ = http.NewRequestWithContext(t.Context(), http.MethodGet, srvDo.URL+"/api/auth/nojson", nil) resp, err = cl.(*piholeClientV6).do(rq) if resp != nil { t.Fatal(err) } if err == nil { t.Fatal("Should have an error") } if !strings.HasPrefix(err.Error(), "failed to unmarshal error response") { t.Fatal("Expected error for unmarshal", err) } // Test Unauthorized retry failed rq, _ = http.NewRequestWithContext(t.Context(), http.MethodGet, srvDo.URL+"/api/auth/401", nil) resp, err = cl.(*piholeClientV6).do(rq) if resp != nil { t.Fatal(err) } if err == nil { t.Fatal("Should have an error") } if !strings.HasPrefix(err.Error(), "max tries reached for token renewal") { t.Fatal("Expected error for max tries reached", err) } } func TestDoRetryOne(t *testing.T) { nbCall := 0 srvRetry := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/auth" && r.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // Return bad content w.Write([]byte(`{ "session": { "valid": true, "totp": false, "sid": "123465468", "csrf": "csrfvalue", "validity": 1800, "message": "password correct" }, "took": 0.24 }`)) } else if r.URL.Path == "/api/auth/401" && r.Method == http.MethodGet { if nbCall == 0 { w.WriteHeader(http.StatusUnauthorized) // Return bad content w.Write([]byte(`{ "error": { "key": "401", "message": "Expired token", "hint": "Expired token" }, "took": 0.25 }`)) } else { w.WriteHeader(http.StatusOK) // Return bad content w.Write([]byte(`Success`)) } nbCall += 1 } }) defer srvRetry.Close() // Create a client cfgRetryOK := PiholeConfig{ Server: srvRetry.URL, APIVersion: "6", } clRetryOK, err := newPiholeClientV6(cfgRetryOK) clRetryOK.(*piholeClientV6).token = "valid" // Test Unauthorized refresh OK rq, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, srvRetry.URL+"/api/auth/401", nil) resp, err := clRetryOK.(*piholeClientV6).do(rq) if err != nil { t.Fatal("Should succeed", err) } if string(resp) != "Success" { t.Fatal("Should have a response") } } func TestDoV6AdditionalCases(t *testing.T) { t.Run("http client error", func(t *testing.T) { client := &piholeClientV6{ httpClient: &http.Client{ Transport: &errorTransportV6{}, }, } req, _ := http.NewRequest(http.MethodGet, "http://localhost", nil) _, err := client.do(req) if err == nil { t.Fatal("expected an error, but got none") } if !strings.Contains(err.Error(), "network error") { t.Fatalf("expected error to contain 'network error', but got '%v'", err) } }) t.Run("item already present", func(t *testing.T) { server := newTestServerV6(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{ "error": { "key": "bad_request", "message": "Item already present", "hint": "The item you're trying to add already exists" }, "took": 0.1 }`)) }) defer server.Close() client := &piholeClientV6{ httpClient: server.Client(), token: "test-token", } req, _ := http.NewRequest(http.MethodPut, server.URL+"/api/test", nil) resp, err := client.do(req) if err != nil { t.Fatalf("expected no error for 'Item already present', but got '%v'", err) } if resp == nil { t.Fatal("expected response, but got nil") } }) t.Run("404 on DELETE", func(t *testing.T) { server := newTestServerV6(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{ "error": { "key": "not_found", "message": "Item not found", "hint": "The item you're trying to delete does not exist" }, "took": 0.1 }`)) }) defer server.Close() client := &piholeClientV6{ httpClient: server.Client(), token: "test-token", } req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/test", nil) resp, err := client.do(req) if err != nil { t.Fatalf("expected no error for 404 on DELETE, but got '%v'", err) } if resp == nil { t.Fatal("expected response, but got nil") } }) } func TestCreateRecordV6(t *testing.T) { var ep *endpoint.Endpoint srvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPut && (r.URL.Path == "/api/config/dns/hosts/192.168.1.1 test.example.com" || r.URL.Path == "/api/config/dns/hosts/fc00::1:192:168:1:1 test.example.com" || r.URL.Path == "/api/config/dns/cnameRecords/source1.example.com,target1.domain.com" || r.URL.Path == "/api/config/dns/hosts/192.168.1.2 test.example.com" || r.URL.Path == "/api/config/dns/hosts/192.168.1.3 test.example.com" || r.URL.Path == "/api/config/dns/hosts/fc00::1:192:168:1:2 test.example.com" || r.URL.Path == "/api/config/dns/hosts/fc00::1:192:168:1:3 test.example.com" || r.URL.Path == "/api/config/dns/cnameRecords/source2.example.com,target2.domain.com,500") { // Return A records w.WriteHeader(http.StatusCreated) } else { http.NotFound(w, r) } }) defer srvr.Close() // Create a client cfg := PiholeConfig{ Server: srvr.URL, APIVersion: "6", DomainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } cl, err := newPiholeClientV6(cfg) if err != nil { t.Fatal(err) } // Test create A record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create multiple A records ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.2", "192.168.1.3"}, RecordType: endpoint.RecordTypeA, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create AAAA record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create multiple AAAA records ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"fc00::1:192:168:1:2", "fc00::1:192:168:1:3"}, RecordType: endpoint.RecordTypeAAAA, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create CNAME record ep = &endpoint.Endpoint{ DNSName: "source1.example.com", Targets: []string{"target1.domain.com"}, RecordType: endpoint.RecordTypeCNAME, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create CNAME record with TTL ep = &endpoint.Endpoint{ DNSName: "source2.example.com", Targets: []string{"target2.domain.com"}, RecordTTL: endpoint.TTL(500), RecordType: endpoint.RecordTypeCNAME, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create CNAME record with multiple targets and ensure it fails ep = &endpoint.Endpoint{ DNSName: "source3.example.com", Targets: []string{"target3.domain.com", "target4.domain.com"}, RecordType: endpoint.RecordTypeCNAME, } if err := cl.createRecord(t.Context(), ep); err == nil { t.Fatal(err) } // Test create a wildcard record and ensure it fails ep = &endpoint.Endpoint{ DNSName: "*.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } if err := cl.createRecord(t.Context(), ep); err == nil { t.Fatal(err) } // Skip not matching domain ep = &endpoint.Endpoint{ DNSName: "foo.bar.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } err = cl.createRecord(t.Context(), ep) if err != nil { t.Fatal("Should not return error on non filtered domain") } // Not supported type ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.1"}, RecordType: "not a type", } err = cl.createRecord(t.Context(), ep) if err != nil { t.Fatal("Should not return error on unsupported type") } // Create a client cfgDr := PiholeConfig{ Server: srvr.URL, APIVersion: "6", DomainFilter: endpoint.NewDomainFilter([]string{"example.com"}), DryRun: true, } clDr, err := newPiholeClientV6(cfgDr) if err != nil { t.Fatal(err) } // Skip Dry Run ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } err = clDr.createRecord(t.Context(), ep) if err != nil { t.Fatal("Should not return error on dry run") } // skip missing targets ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{}, RecordType: endpoint.RecordTypeA, } err = clDr.createRecord(t.Context(), ep) if err != nil { t.Fatal("Should not return error on missing targets") } } func TestDeleteRecordV6(t *testing.T) { var ep *endpoint.Endpoint srvr := newTestServerV6(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete && (r.URL.Path == "/api/config/dns/hosts/192.168.1.1 test.example.com" || r.URL.Path == "/api/config/dns/hosts/fc00::1:192:168:1:1 test.example.com" || r.URL.Path == "/api/config/dns/cnameRecords/source1.example.com,target1.domain.com" || r.URL.Path == "/api/config/dns/cnameRecords/source2.example.com,target2.domain.com,500") { // Return A records w.WriteHeader(http.StatusNoContent) } else { http.NotFound(w, r) } }) defer srvr.Close() // Create a client cfg := PiholeConfig{ Server: srvr.URL, APIVersion: "6", } cl, err := newPiholeClientV6(cfg) if err != nil { t.Fatal(err) } // Test delete A record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } if err := cl.deleteRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test delete AAAA record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, } if err := cl.deleteRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test delete CNAME record ep = &endpoint.Endpoint{ DNSName: "source1.example.com", Targets: []string{"target1.domain.com"}, RecordType: endpoint.RecordTypeCNAME, } if err := cl.deleteRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test delete CNAME record with TTL ep = &endpoint.Endpoint{ DNSName: "source2.example.com", Targets: []string{"target2.domain.com"}, RecordTTL: endpoint.TTL(500), RecordType: endpoint.RecordTypeCNAME, } if err := cl.deleteRecord(t.Context(), ep); err != nil { t.Fatal(err) } } ================================================ FILE: provider/pihole/client_test.go ================================================ /* Copyright 2017 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 pihole import ( "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "sigs.k8s.io/external-dns/endpoint" ) func newTestServer(t *testing.T, hdlr http.HandlerFunc) *httptest.Server { t.Helper() svr := httptest.NewServer(hdlr) return svr } func TestNewPiholeClient(t *testing.T) { // Test correct error on no server provided _, err := newPiholeClient(PiholeConfig{}) if err == nil { t.Error("Expected error from config with no server") } else if !errors.Is(err, ErrNoPiholeServer) { t.Error("Expected ErrNoPiholeServer, got", err) } // Test new client with no password. Should create the // client cleanly. cl, err := newPiholeClient(PiholeConfig{ Server: "test", }) if err != nil { t.Fatal(err) } if _, ok := cl.(*piholeClient); !ok { t.Error("Did not create a new pihole client") } // Create a test server for auth tests srvr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { t.Fatal(err) } pw := r.Form.Get("pw") if pw != "correct" { // Pihole actually server side renders the fact that you failed, normal 200 _, err = w.Write([]byte("Invalid")) if err != nil { t.Fatal(err) } return } // This is a subset of what happens on successful login _, err = w.Write([]byte(` `)) if err != nil { t.Fatal(err) } }) defer srvr.Close() // Test invalid password _, err = newPiholeClient( PiholeConfig{Server: srvr.URL, Password: "wrong"}, ) if err == nil { t.Error("Expected error for creating client with invalid password") } // Test correct password cl, err = newPiholeClient( PiholeConfig{Server: srvr.URL, Password: "correct"}, ) if err != nil { t.Fatal(err) } if cl.(*piholeClient).token != "supersecret" { t.Error("Parsed invalid token from login response:", cl.(*piholeClient).token) } } // Helper function to validate records against expected values func ValidateRecords(t *testing.T, records []*endpoint.Endpoint, expected [][]string, expectedCount int, recordType string) { t.Helper() if len(records) != expectedCount { t.Fatalf("Expected %d %s records returned, got: %d", expectedCount, recordType, len(records)) } for idx, rec := range records { if rec.DNSName != expected[idx][0] { t.Errorf("Got invalid DNS Name: %s, expected: %s", rec.DNSName, expected[idx][0]) } if rec.Targets[0] != expected[idx][1] { t.Errorf("Got invalid target: %s, expected: %s", rec.Targets[0], expected[idx][1]) } } } // Helper function to test record retrieval for a specific type func CheckRecordRetrieval(t *testing.T, cl *piholeClient, recordType string, expected [][]string, expectedCount int) { t.Helper() records, err := cl.listRecords(t.Context(), recordType) if err != nil { t.Fatal(err) } ValidateRecords(t, records, expected, expectedCount, recordType) } func TestListRecords(t *testing.T) { srvr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { t.Fatal(err) } if r.Form.Get("action") != "get" { t.Error("Expected 'get' action in form from client") } if strings.Contains(r.URL.Path, "cname") { _, err = w.Write([]byte(` { "data": [ ["test4.example.com", "cname.example.com"], ["test5.example.com", "cname.example.com"], ["test6.match.com", "cname.example.com"] ] } `)) if err != nil { t.Fatal(err) } return } // Pihole makes no distinction between A and AAAA records _, err = w.Write([]byte(` { "data": [ ["test1.example.com", "192.168.1.1"], ["test2.example.com", "192.168.1.2"], ["test3.match.com", "192.168.1.3"], ["test1.example.com", "fc00::1:192:168:1:1"], ["test2.example.com", "fc00::1:192:168:1:2"], ["test3.match.com", "fc00::1:192:168:1:3"] ] } `)) if err != nil { t.Fatal(err) } }) defer srvr.Close() // Create a client cfg := PiholeConfig{ Server: srvr.URL, } cl, err := newPiholeClient(cfg) if err != nil { t.Fatal(err) } // Test retrieve A records unfiltered CheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeA, [][]string{ {"test1.example.com", "192.168.1.1"}, {"test2.example.com", "192.168.1.2"}, {"test3.match.com", "192.168.1.3"}, }, 3) // Test retrieve AAAA records unfiltered CheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeAAAA, [][]string{ {"test1.example.com", "fc00::1:192:168:1:1"}, {"test2.example.com", "fc00::1:192:168:1:2"}, {"test3.match.com", "fc00::1:192:168:1:3"}, }, 3) // Test retrieve CNAME records unfiltered CheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeCNAME, [][]string{ {"test4.example.com", "cname.example.com"}, {"test5.example.com", "cname.example.com"}, {"test6.match.com", "cname.example.com"}, }, 3) // Same tests but with a domain filter cfg.DomainFilter = endpoint.NewDomainFilter([]string{"match.com"}) cl, err = newPiholeClient(cfg) if err != nil { t.Fatal(err) } // Test retrieve A records filtered CheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeA, [][]string{ {"test3.match.com", "192.168.1.3"}, }, 1) // Test retrieve AAAA records filtered CheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeAAAA, [][]string{ {"test3.match.com", "fc00::1:192:168:1:3"}, }, 1) // Test retrieve CNAME records filtered CheckRecordRetrieval(t, cl.(*piholeClient), endpoint.RecordTypeCNAME, [][]string{ {"test6.match.com", "cname.example.com"}, }, 1) } func TestErrorScenarios(t *testing.T) { // Test errors token srvrErr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { err := r.ParseForm() if err != nil { t.Fatal(err) } pw := r.Form.Get("pw") if pw != "" { if pw != "correct" { _, err = w.Write([]byte("Invalid")) if err != nil { t.Fatal(err) } return } } if strings.Contains(r.URL.Path, "admin/scripts/pi-hole/php/customcname.php") && r.Form.Get("token") == "correct" { _, err = w.Write([]byte(` { "nodata": [ ["nodata", "no"] ] } `)) if err != nil { t.Fatal(err) } } }) defer srvrErr.Close() cfgExpired := PiholeConfig{ Server: srvrErr.URL, } clExpired, err := newPiholeClient(cfgExpired) if err != nil { t.Fatal(err) } // set clExpired.token to a valid token clExpired.(*piholeClient).token = "expired" clExpired.(*piholeClient).cfg.Password = "notcorrect" _, err = clExpired.listRecords(t.Context(), "notarealrecordtype") if err == nil { t.Fatal("Should return error, type is unknown ! ") } _, err = clExpired.listRecords(t.Context(), endpoint.RecordTypeCNAME) if err == nil { t.Fatal("Should return error on failed auth ! ") } clExpired.(*piholeClient).token = "correct" clExpired.(*piholeClient).cfg.Password = "correct" cnamerecs, err := clExpired.listRecords(t.Context(), endpoint.RecordTypeCNAME) if err != nil { t.Fatal(err) } if len(cnamerecs) != 0 { t.Fatal("Should return empty on missing data in response ! ") } } func TestCreateRecord(t *testing.T) { var ep *endpoint.Endpoint srvr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { r.ParseForm() if r.Form.Get("action") != "add" { t.Error("Expected 'add' action in form from client") } if r.Form.Get("domain") != ep.DNSName { t.Error("Invalid domain in form:", r.Form.Get("domain"), "Expected:", ep.DNSName) } switch ep.RecordType { case endpoint.RecordTypeA: if r.Form.Get("ip") != ep.Targets[0] { t.Error("Invalid ip in form:", r.Form.Get("ip"), "Expected:", ep.Targets[0]) } // Pihole makes no distinction between A and AAAA records case endpoint.RecordTypeAAAA: if r.Form.Get("ip") != ep.Targets[0] { t.Error("Invalid ip in form:", r.Form.Get("ip"), "Expected:", ep.Targets[0]) } case endpoint.RecordTypeCNAME: if r.Form.Get("target") != ep.Targets[0] { t.Error("Invalid target in form:", r.Form.Get("target"), "Expected:", ep.Targets[0]) } } out, err := json.Marshal(actionResponse{ Success: true, Message: "", }) if err != nil { t.Fatal(err) } w.Write(out) }) defer srvr.Close() // Create a client cfg := PiholeConfig{ Server: srvr.URL, DomainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } cl, err := newPiholeClient(cfg) if err != nil { t.Fatal(err) } // Test create A record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create AAAA record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create CNAME record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"test.cname.com"}, RecordType: endpoint.RecordTypeCNAME, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test create a wildcard record and ensure it fails ep = &endpoint.Endpoint{ DNSName: "*.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } cl.(*piholeClient).token = "correct" if err := cl.createRecord(t.Context(), ep); err == nil { t.Fatal(err) } // Skip not matching domain ep = &endpoint.Endpoint{ DNSName: "foo.bar.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal("Should not return error on non filtered domain") } // Not supported type ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.1"}, RecordType: "not a type", } if err := cl.createRecord(t.Context(), ep); err != nil { t.Fatal("Should not return error on unsupported type") } // Create a client cfgDr := PiholeConfig{ Server: srvr.URL, DomainFilter: endpoint.NewDomainFilter([]string{"example.com"}), DryRun: true, } clDr, err := newPiholeClient(cfgDr) if err != nil { t.Fatal(err) } // Skip Dry Run ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } if err := clDr.createRecord(t.Context(), ep); err != nil { t.Fatal("Should not return error on dry run") } } func TestDeleteRecord(t *testing.T) { var ep *endpoint.Endpoint srvr := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { r.ParseForm() if r.Form.Get("action") != "delete" { t.Error("Expected 'delete' action in form from client") } if r.Form.Get("domain") != ep.DNSName { t.Error("Invalid domain in form:", r.Form.Get("domain"), "Expected:", ep.DNSName) } switch ep.RecordType { case endpoint.RecordTypeA: if r.Form.Get("ip") != ep.Targets[0] { t.Error("Invalid ip in form:", r.Form.Get("ip"), "Expected:", ep.Targets[0]) } // Pihole makes no distinction between A and AAAA records case endpoint.RecordTypeAAAA: if r.Form.Get("ip") != ep.Targets[0] { t.Error("Invalid ip in form:", r.Form.Get("ip"), "Expected:", ep.Targets[0]) } case endpoint.RecordTypeCNAME: if r.Form.Get("target") != ep.Targets[0] { t.Error("Invalid target in form:", r.Form.Get("target"), "Expected:", ep.Targets[0]) } } out, err := json.Marshal(actionResponse{ Success: true, Message: "", }) if err != nil { t.Fatal(err) } w.Write(out) }) defer srvr.Close() // Create a client cfg := PiholeConfig{ Server: srvr.URL, } cl, err := newPiholeClient(cfg) if err != nil { t.Fatal(err) } // Test delete A record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, } if err := cl.deleteRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test delete AAAA record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, } if err := cl.deleteRecord(t.Context(), ep); err != nil { t.Fatal(err) } // Test delete CNAME record ep = &endpoint.Endpoint{ DNSName: "test.example.com", Targets: []string{"test.cname.com"}, RecordType: endpoint.RecordTypeCNAME, } if err := cl.deleteRecord(t.Context(), ep); err != nil { t.Fatal(err) } } ================================================ FILE: provider/pihole/pihole.go ================================================ /* Copyright 2017 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 pihole import ( "context" "errors" "slices" "github.com/google/go-cmp/cmp" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) // ErrNoPiholeServer is returned when there is no Pihole server configured // in the environment. var ErrNoPiholeServer = errors.New("no pihole server found in the environment or flags") const ( warningMsg = "Pi-hole v5 API support is deprecated. Set --pihole-api-version=\"6\" to use the Pi-hole v6 API. The v5 API will be removed in a future release." ) // PiholeProvider is an implementation of Provider for Pi-hole Local DNS. type PiholeProvider struct { provider.BaseProvider api piholeAPI apiVersion string } // PiholeConfig is used for configuring a PiholeProvider. type PiholeConfig struct { // The root URL of the Pi-hole server. Server string // An optional password if the server is protected. Password string // Disable verification of TLS certificates. TLSInsecureSkipVerify bool // A filter to apply when looking up and applying records. DomainFilter *endpoint.DomainFilter // Do nothing and log what would have changed to stdout. DryRun bool // PiHole API version =<5 or >=6, default is 5 APIVersion string } // Helper struct for de-duping DNS entry updates. type piholeEntryKey struct { Target string RecordType string } // New creates a Pi-hole provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider( PiholeConfig{ Server: cfg.PiholeServer, Password: cfg.PiholePassword, TLSInsecureSkipVerify: cfg.PiholeTLSInsecureSkipVerify, DomainFilter: domainFilter, DryRun: cfg.DryRun, APIVersion: cfg.PiholeApiVersion, }, ) } // newProvider initializes a new Pi-hole Local DNS based Provider. func newProvider(cfg PiholeConfig) (*PiholeProvider, error) { var api piholeAPI var err error switch cfg.APIVersion { case "6": api, err = newPiholeClientV6(cfg) default: log.Warn(warningMsg) api, err = newPiholeClient(cfg) } if err != nil { return nil, err } return &PiholeProvider{api: api, apiVersion: cfg.APIVersion}, nil } // Records implements Provider, populating a slice of endpoints from // Pi-Hole local DNS. func (p *PiholeProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { aRecords, err := p.api.listRecords(ctx, endpoint.RecordTypeA) if err != nil { return nil, err } aaaaRecords, err := p.api.listRecords(ctx, endpoint.RecordTypeAAAA) if err != nil { return nil, err } cnameRecords, err := p.api.listRecords(ctx, endpoint.RecordTypeCNAME) if err != nil { return nil, err } aRecords = append(aRecords, aaaaRecords...) return append(aRecords, cnameRecords...), nil } // ApplyChanges implements Provider, syncing desired state with the Pi-hole server Local DNS. func (p *PiholeProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { // Handle pure deletes first. for _, ep := range changes.Delete { if err := p.api.deleteRecord(ctx, ep); err != nil { return err } } // Handle updated state - there are no endpoints for updating in place. updateNew := make(map[piholeEntryKey]*endpoint.Endpoint) for _, ep := range changes.UpdateNew { key := piholeEntryKey{ep.DNSName, ep.RecordType} // If the API version is 6, we need to handle multiple targets for the same DNS name. if p.apiVersion == "6" { if existing, ok := updateNew[key]; ok { existing.Targets = append(existing.Targets, ep.Targets...) // Deduplicate targets slices.Sort(existing.Targets) existing.Targets = slices.Compact(existing.Targets) ep = existing } } updateNew[key] = ep } for _, ep := range changes.UpdateOld { // Check if this existing entry has an exact match for an updated entry and skip it if so. key := piholeEntryKey{ep.DNSName, ep.RecordType} if newRecord := updateNew[key]; newRecord != nil { // If the API version is 6, we need to handle multiple targets for the same DNS name. if p.apiVersion == "6" { if cmp.Diff(ep.Targets, newRecord.Targets) == "" { delete(updateNew, key) continue } } else { // For API version <= 5, we only check the first target. if newRecord.Targets[0] == ep.Targets[0] { delete(updateNew, key) continue } } if err := p.api.deleteRecord(ctx, ep); err != nil { return err } } } // Handle pure creates before applying new updated state. for _, ep := range changes.Create { if err := p.api.createRecord(ctx, ep); err != nil { return err } } for _, ep := range updateNew { if err := p.api.createRecord(ctx, ep); err != nil { return err } } return nil } ================================================ FILE: provider/pihole/piholeV6_test.go ================================================ /* 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 pihole import ( "context" "errors" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) var ( endpointSort = cmpopts.SortSlices(func(x, y *endpoint.Endpoint) bool { if x.DNSName < y.DNSName { return true } if x.DNSName > y.DNSName { return false } if x.RecordType < y.RecordType { return true } if x.RecordType > y.RecordType { return false } return x.Targets.String() < y.Targets.String() }) ) type testPiholeClientV6 struct { endpoints []*endpoint.Endpoint requests *requestTrackerV6 trigger string } func (t *testPiholeClientV6) listRecords(_ context.Context, rtype string) ([]*endpoint.Endpoint, error) { out := make([]*endpoint.Endpoint, 0) if t.trigger == "AERROR" { return nil, errors.New("AERROR") } if t.trigger == "AAAAERROR" { return nil, errors.New("AAAAERROR") } if t.trigger == "CNAMEERROR" { return nil, errors.New("CNAMEERROR") } for _, ep := range t.endpoints { if ep.RecordType == rtype { out = append(out, ep) } } return out, nil } func (t *testPiholeClientV6) createRecord(_ context.Context, ep *endpoint.Endpoint) error { t.endpoints = append(t.endpoints, ep) t.requests.createRequests = append(t.requests.createRequests, ep) return nil } func (t *testPiholeClientV6) deleteRecord(_ context.Context, ep *endpoint.Endpoint) error { newEPs := make([]*endpoint.Endpoint, 0) for _, existing := range t.endpoints { if existing.DNSName != ep.DNSName || cmp.Diff(existing.Targets, ep.Targets) != "" || existing.RecordType != ep.RecordType { newEPs = append(newEPs, existing) } } t.endpoints = newEPs t.requests.deleteRequests = append(t.requests.deleteRequests, ep) return nil } type requestTrackerV6 struct { createRequests []*endpoint.Endpoint deleteRequests []*endpoint.Endpoint } func (r *requestTrackerV6) clear() { r.createRequests = nil r.deleteRequests = nil } func TestErrorHandling(t *testing.T) { requests := requestTrackerV6{} p := &PiholeProvider{ api: &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, apiVersion: "6", } p.api.(*testPiholeClientV6).trigger = "AERROR" _, err := p.Records(t.Context()) if err.Error() != "AERROR" { t.Fatal(err) } p.api.(*testPiholeClientV6).trigger = "AAAAERROR" _, err = p.Records(t.Context()) if err.Error() != "AAAAERROR" { t.Fatal(err) } p.api.(*testPiholeClientV6).trigger = "CNAMEERROR" _, err = p.Records(t.Context()) if err.Error() != "CNAMEERROR" { t.Fatal(err) } } func TestNewPiholeProviderV6(t *testing.T) { // Test invalid configuration _, err := newProvider(PiholeConfig{APIVersion: "7"}) if err == nil { t.Error("Expected error from invalid configuration") } // Test valid configuration _, err = newProvider(PiholeConfig{Server: "test.example.com", APIVersion: "6"}) if err != nil { t.Error("Expected no error from valid configuration, got:", err) } } func TestProviderV6(t *testing.T) { requests := requestTrackerV6{} p := &PiholeProvider{ api: &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, apiVersion: "6", } t.Run("Initial Records", func(t *testing.T) { records, err := p.Records(t.Context()) if err != nil { t.Fatal(err) } if len(records) != 0 { t.Fatal("Expected empty list of records, got:", records) } }) t.Run("Create Records", func(t *testing.T) { records := []*endpoint.Endpoint{ {DNSName: "test1.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test3.example.com", Targets: []string{"192.168.1.3"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA}, {DNSName: "test2.example.com", Targets: []string{"fc00::1:192:168:1:2"}, RecordType: endpoint.RecordTypeAAAA}, {DNSName: "test3.example.com", Targets: []string{"fc00::1:192:168:1:3"}, RecordType: endpoint.RecordTypeAAAA}, } if err := p.ApplyChanges(t.Context(), &plan.Changes{Create: records}); err != nil { t.Fatal(err) } newRecords, err := p.Records(t.Context()) if err != nil { t.Fatal(err) } if !cmp.Equal(newRecords, records, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) { t.Error("Records are not equal:", cmp.Diff(newRecords, records, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort)) } if !cmp.Equal(requests.createRequests, records, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) { t.Error("Create requests are not equal:", cmp.Diff(requests.createRequests, records, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort)) } if len(requests.deleteRequests) != 0 { t.Fatal("Expected no delete requests, got:", requests.deleteRequests) } requests.clear() }) t.Run("Delete Records", func(t *testing.T) { recordToDeleteA := &endpoint.Endpoint{DNSName: "test3.example.com", Targets: []string{"192.168.1.3"}, RecordType: endpoint.RecordTypeA} if err := p.ApplyChanges(t.Context(), &plan.Changes{Delete: []*endpoint.Endpoint{recordToDeleteA}}); err != nil { t.Fatal(err) } recordToDeleteAAAA := &endpoint.Endpoint{DNSName: "test3.example.com", Targets: []string{"fc00::1:192:168:1:3"}, RecordType: endpoint.RecordTypeAAAA} if err := p.ApplyChanges(t.Context(), &plan.Changes{Delete: []*endpoint.Endpoint{recordToDeleteAAAA}}); err != nil { t.Fatal(err) } expectedRecords := []*endpoint.Endpoint{ {DNSName: "test1.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA}, {DNSName: "test2.example.com", Targets: []string{"fc00::1:192:168:1:2"}, RecordType: endpoint.RecordTypeAAAA}, } newRecords, err := p.Records(t.Context()) if err != nil { t.Fatal(err) } if !cmp.Equal(newRecords, expectedRecords, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) { t.Error("Records are not equal:", cmp.Diff(newRecords, expectedRecords, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort)) } if len(requests.createRequests) != 0 { t.Fatal("Expected no create requests, got:", requests.createRequests) } expectedDeletes := []*endpoint.Endpoint{recordToDeleteA, recordToDeleteAAAA} if !cmp.Equal(requests.deleteRequests, expectedDeletes, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) { t.Error("Delete requests are not equal:", cmp.Diff(requests.deleteRequests, expectedDeletes, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort)) } requests.clear() }) t.Run("Update Records", func(t *testing.T) { updateOld := []*endpoint.Endpoint{ {DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test2.example.com", Targets: []string{"fc00::1:192:168:1:2"}, RecordType: endpoint.RecordTypeAAAA}, } updateNew := []*endpoint.Endpoint{ {DNSName: "test2.example.com", Targets: []string{"10.0.0.1"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test2.example.com", Targets: []string{"fc00::1:10:0:0:1"}, RecordType: endpoint.RecordTypeAAAA}, } if err := p.ApplyChanges(t.Context(), &plan.Changes{UpdateOld: updateOld, UpdateNew: updateNew}); err != nil { t.Fatal(err) } expectedRecords := []*endpoint.Endpoint{ {DNSName: "test1.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test2.example.com", Targets: []string{"10.0.0.1"}, RecordType: endpoint.RecordTypeA}, {DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA}, {DNSName: "test2.example.com", Targets: []string{"fc00::1:10:0:0:1"}, RecordType: endpoint.RecordTypeAAAA}, } newRecords, err := p.Records(t.Context()) if err != nil { t.Fatal(err) } if !cmp.Equal(newRecords, expectedRecords, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) { t.Error("Records are not equal:", cmp.Diff(newRecords, expectedRecords, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort)) } if !cmp.Equal(requests.createRequests, updateNew, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort) { t.Error("Create requests are not equal:", cmp.Diff(requests.createRequests, updateNew, cmpopts.IgnoreUnexported(endpoint.Endpoint{}), endpointSort)) } if !cmp.Equal(requests.deleteRequests, updateOld, cmpopts.IgnoreUnexported(endpoint.Endpoint{})) { t.Error("Delete requests are not equal:", cmp.Diff(requests.deleteRequests, updateOld, cmpopts.IgnoreUnexported(endpoint.Endpoint{}))) } requests.clear() }) } func TestProviderV6MultipleTargets(t *testing.T) { requests := requestTrackerV6{} p := &PiholeProvider{ api: &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, apiVersion: "6", } t.Run("Update with multiple targets - merge and deduplicate", func(t *testing.T) { // Create initial records with multiple targets initialRecords := []*endpoint.Endpoint{ {DNSName: "multi.example.com", Targets: []string{"192.168.1.1", "192.168.1.2"}, RecordType: endpoint.RecordTypeA}, } if err := p.ApplyChanges(t.Context(), &plan.Changes{Create: initialRecords}); err != nil { t.Fatal(err) } requests.clear() // Update with new targets that should be merged updateOld := []*endpoint.Endpoint{ {DNSName: "multi.example.com", Targets: []string{"192.168.1.1", "192.168.1.2"}, RecordType: endpoint.RecordTypeA}, } updateNew := []*endpoint.Endpoint{ {DNSName: "multi.example.com", Targets: []string{"192.168.1.3"}, RecordType: endpoint.RecordTypeA}, {DNSName: "multi.example.com", Targets: []string{"192.168.1.4"}, RecordType: endpoint.RecordTypeA}, {DNSName: "multi.example.com", Targets: []string{"192.168.1.3"}, RecordType: endpoint.RecordTypeA}, // Duplicate to test deduplication } if err := p.ApplyChanges(t.Context(), &plan.Changes{UpdateOld: updateOld, UpdateNew: updateNew}); err != nil { t.Fatal(err) } // Verify that targets were merged and deduplicated expectedCreate := []*endpoint.Endpoint{ {DNSName: "multi.example.com", Targets: []string{"192.168.1.3", "192.168.1.4"}, RecordType: endpoint.RecordTypeA}, } if len(requests.createRequests) != 1 { t.Fatalf("Expected 1 create request, got %d", len(requests.createRequests)) } if !cmp.Equal(requests.createRequests[0].Targets, expectedCreate[0].Targets) { t.Error("Targets not merged correctly:", cmp.Diff(requests.createRequests[0].Targets, expectedCreate[0].Targets)) } if len(requests.deleteRequests) != 1 { t.Fatalf("Expected 1 delete request, got %d", len(requests.deleteRequests)) } requests.clear() }) t.Run("Update with exact match - should skip delete", func(t *testing.T) { // Update where old and new have the same targets (exact match) updateOld := []*endpoint.Endpoint{ {DNSName: "multi.example.com", Targets: []string{"192.168.1.3", "192.168.1.4"}, RecordType: endpoint.RecordTypeA}, } updateNew := []*endpoint.Endpoint{ {DNSName: "multi.example.com", Targets: []string{"192.168.1.3", "192.168.1.4"}, RecordType: endpoint.RecordTypeA}, } if err := p.ApplyChanges(t.Context(), &plan.Changes{UpdateOld: updateOld, UpdateNew: updateNew}); err != nil { t.Fatal(err) } // Should not create or delete anything since targets are the same if len(requests.createRequests) != 0 { t.Fatalf("Expected no create requests for exact match, got %d", len(requests.createRequests)) } if len(requests.deleteRequests) != 0 { t.Fatalf("Expected no delete requests for exact match, got %d", len(requests.deleteRequests)) } requests.clear() }) } ================================================ FILE: provider/pihole/pihole_test.go ================================================ /* Copyright 2017 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 pihole import ( "context" "reflect" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/plan" ) type testPiholeClient struct { endpoints []*endpoint.Endpoint requests *requestTracker } func (t *testPiholeClient) listRecords(_ context.Context, rtype string) ([]*endpoint.Endpoint, error) { out := make([]*endpoint.Endpoint, 0) for _, ep := range t.endpoints { if ep.RecordType == rtype { out = append(out, ep) } } return out, nil } func (t *testPiholeClient) createRecord(_ context.Context, ep *endpoint.Endpoint) error { t.endpoints = append(t.endpoints, ep) t.requests.createRequests = append(t.requests.createRequests, ep) return nil } func (t *testPiholeClient) deleteRecord(_ context.Context, ep *endpoint.Endpoint) error { newEPs := make([]*endpoint.Endpoint, 0) for _, existing := range t.endpoints { if existing.DNSName != ep.DNSName && existing.Targets[0] != ep.Targets[0] { newEPs = append(newEPs, existing) } } t.endpoints = newEPs t.requests.deleteRequests = append(t.requests.deleteRequests, ep) return nil } type requestTracker struct { createRequests []*endpoint.Endpoint deleteRequests []*endpoint.Endpoint } func (r *requestTracker) clear() { r.createRequests = nil r.deleteRequests = nil } func TestNewProvider(t *testing.T) { // Test invalid configuration _, err := newProvider(PiholeConfig{}) if err == nil { t.Error("Expected error from invalid configuration") } // Test valid configuration _, err = newProvider(PiholeConfig{Server: "test.example.com"}) if err != nil { t.Error("Expected no error from valid configuration, got:", err) } } func TestNewPiholeProvider_APIVersions(t *testing.T) { tests := []struct { name string config PiholeConfig wantMsg bool }{ { name: "API version 5 with server", config: PiholeConfig{ APIVersion: "5", Server: "test.example.com", }, wantMsg: true, }, { name: "API version 6 with server", config: PiholeConfig{ APIVersion: "6", Server: "test.example.com", }, wantMsg: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) _, err := newProvider(tt.config) require.NoError(t, err) if tt.wantMsg { logtest.TestHelperLogContains(warningMsg, hook, t) } }) } } func TestProvider_InitialState(t *testing.T) { requests := requestTracker{} p := &PiholeProvider{ api: &testPiholeClient{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, } records, err := p.Records(t.Context()) if err != nil { t.Fatal(err) } if len(records) != 0 { t.Fatal("Expected empty list of records, got:", records) } } func TestProvider_CreateRecords(t *testing.T) { requests := requestTracker{} p := &PiholeProvider{ api: &testPiholeClient{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, } records := []*endpoint.Endpoint{ { DNSName: "test1.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test3.example.com", Targets: []string{"192.168.1.3"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, }, { DNSName: "test2.example.com", Targets: []string{"fc00::1:192:168:1:2"}, RecordType: endpoint.RecordTypeAAAA, }, { DNSName: "test3.example.com", Targets: []string{"fc00::1:192:168:1:3"}, RecordType: endpoint.RecordTypeAAAA, }, } if err := p.ApplyChanges(t.Context(), &plan.Changes{ Create: records, }); err != nil { t.Fatal(err) } newRecords, err := p.Records(t.Context()) if err != nil { t.Fatal(err) } if len(newRecords) != 6 { t.Fatal("Expected list of 6 records, got:", records) } if len(requests.createRequests) != 6 { t.Fatal("Expected 6 create requests, got:", requests.createRequests) } if len(requests.deleteRequests) != 0 { t.Fatal("Expected no delete requests, got:", requests.deleteRequests) } for idx, record := range records { if newRecords[idx].DNSName != record.DNSName { t.Error("DNS Name malformed on retrieval, got:", newRecords[idx].DNSName, "expected:", record.DNSName) } if newRecords[idx].Targets[0] != record.Targets[0] { t.Error("Targets malformed on retrieval, got:", newRecords[idx].Targets, "expected:", record.Targets) } if !reflect.DeepEqual(requests.createRequests[idx], record) { t.Error("Unexpected create request, got:", newRecords[idx].DNSName, "expected:", record.DNSName) } } requests.clear() } func TestProvider_DeleteRecords(t *testing.T) { requests := requestTracker{} p := &PiholeProvider{ api: &testPiholeClient{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, } records := []*endpoint.Endpoint{ { DNSName: "test1.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, }, { DNSName: "test2.example.com", Targets: []string{"fc00::1:192:168:1:2"}, RecordType: endpoint.RecordTypeAAAA, }, } // Create initial records if err := p.ApplyChanges(t.Context(), &plan.Changes{ Create: records, }); err != nil { t.Fatal(err) } recordToDeleteA := endpoint.Endpoint{ DNSName: "test3.example.com", Targets: []string{"192.168.1.3"}, RecordType: endpoint.RecordTypeA, } if err := p.ApplyChanges(t.Context(), &plan.Changes{ Delete: []*endpoint.Endpoint{ &recordToDeleteA, }, }); err != nil { t.Fatal(err) } recordToDeleteAAAA := endpoint.Endpoint{ DNSName: "test3.example.com", Targets: []string{"fc00::1:192:168:1:3"}, RecordType: endpoint.RecordTypeAAAA, } if err := p.ApplyChanges(t.Context(), &plan.Changes{ Delete: []*endpoint.Endpoint{ &recordToDeleteAAAA, }, }); err != nil { t.Fatal(err) } newRecords, err := p.Records(t.Context()) if err != nil { t.Fatal(err) } if len(newRecords) != 4 { t.Fatal("Expected list of 4 records, got:", records) } if len(requests.createRequests) != 4 { t.Fatal("Expected 4 create requests, got:", requests.createRequests) } if len(requests.deleteRequests) != 2 { t.Fatal("Expected 2 delete request, got:", requests.deleteRequests) } for idx, record := range records { if newRecords[idx].DNSName != record.DNSName { t.Error("DNS Name malformed on retrieval, got:", newRecords[idx].DNSName, "expected:", record.DNSName) } if newRecords[idx].Targets[0] != record.Targets[0] { t.Error("Targets malformed on retrieval, got:", newRecords[idx].Targets, "expected:", record.Targets) } } if !reflect.DeepEqual(requests.deleteRequests[0], &recordToDeleteA) { t.Error("Unexpected delete request, got:", requests.deleteRequests[0], "expected:", recordToDeleteA) } if !reflect.DeepEqual(requests.deleteRequests[1], &recordToDeleteAAAA) { t.Error("Unexpected delete request, got:", requests.deleteRequests[1], "expected:", recordToDeleteAAAA) } requests.clear() } func TestProvider_UpdateRecords(t *testing.T) { requests := requestTracker{} p := &PiholeProvider{ api: &testPiholeClient{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, } // Create initial records initialRecords := []*endpoint.Endpoint{ { DNSName: "test1.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, }, { DNSName: "test2.example.com", Targets: []string{"fc00::1:192:168:1:2"}, RecordType: endpoint.RecordTypeAAAA, }, } if err := p.ApplyChanges(t.Context(), &plan.Changes{ Create: initialRecords, }); err != nil { t.Fatal(err) } requests.clear() // Update records updateOld := []*endpoint.Endpoint{ { DNSName: "test1.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, }, { DNSName: "test2.example.com", Targets: []string{"fc00::1:192:168:1:2"}, RecordType: endpoint.RecordTypeAAAA, }, } updateNew := []*endpoint.Endpoint{ { DNSName: "test1.example.com", Targets: []string{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test2.example.com", Targets: []string{"10.0.0.1"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, RecordType: endpoint.RecordTypeAAAA, }, { DNSName: "test2.example.com", Targets: []string{"fc00::1:10:0:0:1"}, RecordType: endpoint.RecordTypeAAAA, }, } if err := p.ApplyChanges(t.Context(), &plan.Changes{ UpdateOld: updateOld, UpdateNew: updateNew, }); err != nil { t.Fatal(err) } newRecords, err := p.Records(t.Context()) if err != nil { t.Fatal(err) } if len(newRecords) != 4 { t.Fatal("Expected list of 4 records, got:", newRecords) } if len(requests.createRequests) != 2 { t.Fatal("Expected 2 create request, got:", requests.createRequests) } if len(requests.deleteRequests) != 2 { t.Fatal("Expected 2 delete request, got:", requests.deleteRequests) } for idx, record := range updateNew { if newRecords[idx].DNSName != record.DNSName { t.Error("DNS Name malformed on retrieval, got:", newRecords[idx].DNSName, "expected:", record.DNSName) } if newRecords[idx].Targets[0] != record.Targets[0] { t.Error("Targets malformed on retrieval, got:", newRecords[idx].Targets, "expected:", record.Targets) } } expectedCreateA := endpoint.Endpoint{ DNSName: "test2.example.com", Targets: []string{"10.0.0.1"}, RecordType: endpoint.RecordTypeA, } expectedDeleteA := endpoint.Endpoint{ DNSName: "test2.example.com", Targets: []string{"192.168.1.2"}, RecordType: endpoint.RecordTypeA, } expectedCreateAAAA := endpoint.Endpoint{ DNSName: "test2.example.com", Targets: []string{"fc00::1:10:0:0:1"}, RecordType: endpoint.RecordTypeAAAA, } expectedDeleteAAAA := endpoint.Endpoint{ DNSName: "test2.example.com", Targets: []string{"fc00::1:192:168:1:2"}, RecordType: endpoint.RecordTypeAAAA, } for _, request := range requests.createRequests { switch request.RecordType { case endpoint.RecordTypeA: if !reflect.DeepEqual(request, &expectedCreateA) { t.Error("Unexpected create request, got:", request, "expected:", &expectedCreateA) } case endpoint.RecordTypeAAAA: if !reflect.DeepEqual(request, &expectedCreateAAAA) { t.Error("Unexpected create request, got:", request, "expected:", &expectedCreateAAAA) } } } for _, request := range requests.deleteRequests { switch request.RecordType { case endpoint.RecordTypeA: if !reflect.DeepEqual(request, &expectedDeleteA) { t.Error("Unexpected delete request, got:", request, "expected:", &expectedDeleteA) } case endpoint.RecordTypeAAAA: if !reflect.DeepEqual(request, &expectedDeleteAAAA) { t.Error("Unexpected delete request, got:", request, "expected:", &expectedDeleteAAAA) } } } requests.clear() } ================================================ FILE: provider/plural/client.go ================================================ /* Copyright 2022 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 plural import ( "context" "fmt" "net/http" "net/url" "strings" "github.com/Yamashou/gqlgenc/clientv2" "github.com/pluralsh/gqlclient" "github.com/pluralsh/gqlclient/pkg/utils" ) type authedTransport struct { key string wrapped http.RoundTripper } func (t *authedTransport) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("Authorization", "Bearer "+t.key) return t.wrapped.RoundTrip(req) } type Client interface { DnsRecords() ([]*DnsRecord, error) CreateRecord(record *DnsRecord) (*DnsRecord, error) DeleteRecord(name, ttype string) error } type Config struct { Token string Endpoint string Cluster string Provider string } type client struct { ctx context.Context pluralClient *gqlclient.Client config *Config } type DnsRecord struct { Type string Name string Records []string } func NewClient(conf *Config) (Client, error) { base, err := conf.BaseUrl() if err != nil { return nil, err } httpClient := http.Client{ Transport: &authedTransport{ key: conf.Token, wrapped: http.DefaultTransport, }, } endpoint := base + "/gql" return &client{ ctx: context.Background(), pluralClient: gqlclient.NewClient(&httpClient, endpoint, &clientv2.Options{}), config: conf, }, nil } func (c *Config) BaseUrl() (string, error) { host := "https://app.plural.sh" if c.Endpoint != "" { host = fmt.Sprintf("https://%s", c.Endpoint) if _, err := url.Parse(host); err != nil { return "", err } } return host, nil } func (client *client) DnsRecords() ([]*DnsRecord, error) { resp, err := client.pluralClient.GetDNSRecords(client.ctx, client.config.Cluster, gqlclient.Provider(strings.ToUpper(client.config.Provider))) if err != nil { return nil, err } records := make([]*DnsRecord, 0) for _, edge := range resp.DNSRecords.Edges { if edge.Node != nil { record := &DnsRecord{ Type: string(edge.Node.Type), Name: edge.Node.Name, Records: utils.ConvertStringArrayPointer(edge.Node.Records), } records = append(records, record) } } return records, nil } func (client *client) CreateRecord(record *DnsRecord) (*DnsRecord, error) { provider := gqlclient.Provider(strings.ToUpper(client.config.Provider)) cluster := client.config.Cluster attr := gqlclient.DNSRecordAttributes{ Name: record.Name, Type: gqlclient.DNSRecordType(record.Type), Records: []*string{}, } for _, record := range record.Records { attr.Records = append(attr.Records, &record) } resp, err := client.pluralClient.CreateDNSRecord(client.ctx, cluster, provider, attr) if err != nil { return nil, err } return &DnsRecord{ Type: string(resp.CreateDNSRecord.Type), Name: resp.CreateDNSRecord.Name, Records: utils.ConvertStringArrayPointer(resp.CreateDNSRecord.Records), }, nil } func (client *client) DeleteRecord(name, ttype string) error { if _, err := client.pluralClient.DeleteDNSRecord(client.ctx, name, gqlclient.DNSRecordType(ttype)); err != nil { return err } return nil } ================================================ FILE: provider/plural/plural.go ================================================ /* Copyright 2022 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 plural import ( "context" "fmt" "os" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( CreateAction = "c" DeleteAction = "d" ) type PluralProvider struct { provider.BaseProvider Client Client } type RecordChange struct { Action string Record *DnsRecord } // New creates a Plural provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, _ *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(cfg.PluralCluster, cfg.PluralProvider) } func newProvider(cluster, provider string) (*PluralProvider, error) { token := os.Getenv("PLURAL_ACCESS_TOKEN") if token == "" { return nil, fmt.Errorf("no plural access token provided, you must set the PLURAL_ACCESS_TOKEN env var") } config := &Config{ Token: token, Endpoint: os.Getenv("PLURAL_ENDPOINT"), Cluster: cluster, Provider: provider, } cl, err := NewClient(config) if err != nil { return nil, err } return &PluralProvider{ Client: cl, }, nil } func (p *PluralProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { records, err := p.Client.DnsRecords() if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, len(records)) for i, record := range records { endpoints[i] = endpoint.NewEndpoint(record.Name, record.Type, record.Records...) } return endpoints, nil } func (p *PluralProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return endpoints, nil } func (p *PluralProvider) ApplyChanges(_ context.Context, diffs *plan.Changes) error { var changes []*RecordChange for _, ep := range diffs.Create { changes = append(changes, makeChange(CreateAction, ep.Targets, ep)) } for _, desired := range diffs.UpdateNew { changes = append(changes, makeChange(CreateAction, desired.Targets, desired)) } for _, deleted := range diffs.Delete { changes = append(changes, makeChange(DeleteAction, []string{}, deleted)) } return p.applyChanges(changes) } func makeChange(change string, target []string, endpoint *endpoint.Endpoint) *RecordChange { return &RecordChange{ Action: change, Record: &DnsRecord{ Name: endpoint.DNSName, Type: endpoint.RecordType, Records: target, }, } } func (p *PluralProvider) applyChanges(changes []*RecordChange) error { for _, change := range changes { logFields := log.Fields{ "name": change.Record.Name, "type": change.Record.Type, "action": change.Action, } log.WithFields(logFields).Info("Changing record.") if change.Action == CreateAction { _, err := p.Client.CreateRecord(change.Record) if err != nil { return err } } if change.Action == DeleteAction { if err := p.Client.DeleteRecord(change.Record.Name, change.Record.Type); err != nil { return err } } } return nil } ================================================ FILE: provider/plural/plural_test.go ================================================ /* Copyright 2022 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 plural import ( "testing" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/provider" ) type ClientStub struct { mockDnsRecords []*DnsRecord } // CreateRecord provides a mock function with given fields: record func (c *ClientStub) CreateRecord(record *DnsRecord) (*DnsRecord, error) { c.mockDnsRecords = append(c.mockDnsRecords, record) return record, nil } // DeleteRecord provides a mock function with given fields: name, ttype func (c *ClientStub) DeleteRecord(name string, ttype string) error { newRecords := make([]*DnsRecord, 0) for _, record := range c.mockDnsRecords { if record.Name == name && record.Type == ttype { continue } newRecords = append(newRecords, record) } c.mockDnsRecords = newRecords return nil } // DnsRecords provides a mock function with given fields: func (c *ClientStub) DnsRecords() ([]*DnsRecord, error) { return c.mockDnsRecords, nil } func newPluralProvider(pluralDNSRecord []*DnsRecord) *PluralProvider { if pluralDNSRecord == nil { pluralDNSRecord = make([]*DnsRecord, 0) } return &PluralProvider{ BaseProvider: provider.BaseProvider{}, Client: &ClientStub{ mockDnsRecords: pluralDNSRecord, }, } } func TestPluralRecords(t *testing.T) { tests := []struct { name string expectedEndpoints []*endpoint.Endpoint records []*DnsRecord }{ { name: "check records", records: []*DnsRecord{ { Type: endpoint.RecordTypeA, Name: "example.com", Records: []string{"123.123.123.122"}, }, { Type: endpoint.RecordTypeA, Name: "nginx.example.com", Records: []string{"123.123.123.123"}, }, { Type: endpoint.RecordTypeCNAME, Name: "hack.example.com", Records: []string{"bluecatnetworks.com"}, }, { Type: endpoint.RecordTypeTXT, Name: "kdb.example.com", Records: []string{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, }, expectedEndpoints: []*endpoint.Endpoint{ { DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.122"}, }, { DNSName: "nginx.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.123"}, }, { DNSName: "hack.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bluecatnetworks.com"}, }, { DNSName: "kdb.example.com", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { provider := newPluralProvider(test.records) actual, err := provider.Records(t.Context()) if err != nil { t.Fatal(err) } validateEndpoints(t, actual, test.expectedEndpoints) }) } } func TestPluralApplyChangesCreate(t *testing.T) { tests := []struct { name string expectedEndpoints []*endpoint.Endpoint }{ { name: "create new endpoints", expectedEndpoints: []*endpoint.Endpoint{ { DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.122"}, }, { DNSName: "nginx.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.123"}, }, { DNSName: "hack.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bluecatnetworks.com"}, }, { DNSName: "kdb.example.com", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { provider := newPluralProvider(nil) // no records actual, err := provider.Records(t.Context()) if err != nil { t.Fatal(err) } assert.Empty(t, actual, "expected no entries") err = provider.ApplyChanges(t.Context(), &plan.Changes{Create: test.expectedEndpoints}) if err != nil { t.Fatal(err) } actual, err = provider.Records(t.Context()) if err != nil { t.Fatal(err) } validateEndpoints(t, actual, test.expectedEndpoints) }) } } func TestPluralApplyChangesDelete(t *testing.T) { tests := []struct { name string records []*DnsRecord deleteEndpoints []*endpoint.Endpoint expectedEndpoints []*endpoint.Endpoint }{ { name: "delete not existing record", records: []*DnsRecord{ { Type: endpoint.RecordTypeA, Name: "example.com", Records: []string{"123.123.123.122"}, }, { Type: endpoint.RecordTypeA, Name: "nginx.example.com", Records: []string{"123.123.123.123"}, }, { Type: endpoint.RecordTypeCNAME, Name: "hack.example.com", Records: []string{"bluecatnetworks.com"}, }, { Type: endpoint.RecordTypeTXT, Name: "kdb.example.com", Records: []string{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, }, deleteEndpoints: []*endpoint.Endpoint{ { DNSName: "fake.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, }, expectedEndpoints: []*endpoint.Endpoint{ { DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.122"}, }, { DNSName: "nginx.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.123"}, }, { DNSName: "hack.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bluecatnetworks.com"}, }, { DNSName: "kdb.example.com", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, }, }, { name: "delete one record", records: []*DnsRecord{ { Type: endpoint.RecordTypeA, Name: "example.com", Records: []string{"123.123.123.122"}, }, { Type: endpoint.RecordTypeA, Name: "nginx.example.com", Records: []string{"123.123.123.123"}, }, { Type: endpoint.RecordTypeCNAME, Name: "hack.example.com", Records: []string{"bluecatnetworks.com"}, }, { Type: endpoint.RecordTypeTXT, Name: "kdb.example.com", Records: []string{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, }, deleteEndpoints: []*endpoint.Endpoint{ { DNSName: "kdb.example.com", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, }, expectedEndpoints: []*endpoint.Endpoint{ { DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.122"}, }, { DNSName: "nginx.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.123"}, }, { DNSName: "hack.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bluecatnetworks.com"}, }, }, }, { name: "delete all records", records: []*DnsRecord{ { Type: endpoint.RecordTypeA, Name: "example.com", Records: []string{"123.123.123.122"}, }, { Type: endpoint.RecordTypeA, Name: "nginx.example.com", Records: []string{"123.123.123.123"}, }, { Type: endpoint.RecordTypeCNAME, Name: "hack.example.com", Records: []string{"bluecatnetworks.com"}, }, { Type: endpoint.RecordTypeTXT, Name: "kdb.example.com", Records: []string{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, }, deleteEndpoints: []*endpoint.Endpoint{ { DNSName: "kdb.example.com", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"heritage=external-dns,external-dns/owner=default,external-dns/resource=service/openshift-ingress/router-default"}, }, { DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.122"}, }, { DNSName: "nginx.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"123.123.123.123"}, }, { DNSName: "hack.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bluecatnetworks.com"}, }, }, expectedEndpoints: []*endpoint.Endpoint{}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { provider := newPluralProvider(test.records) err := provider.ApplyChanges(t.Context(), &plan.Changes{Delete: test.deleteEndpoints}) if err != nil { t.Fatal(err) } actual, err := provider.Records(t.Context()) if err != nil { t.Fatal(err) } validateEndpoints(t, actual, test.expectedEndpoints) }) } } func validateEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) { assert.True(t, testutils.SameEndpoints(endpoints, expected), "expected and actual endpoints don't match. %s:%s", endpoints, expected) } ================================================ FILE: provider/provider.go ================================================ /* Copyright 2017 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 provider import ( "context" "errors" "fmt" "net" "strings" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) // SoftError is an error, that provider will only log as error instead // of fatal. It is meant for error propagation from providers to tell // that this is a transient error. var SoftError error = errors.New("soft error") //nolint:staticcheck // NewSoftErrorf creates a SoftError with formats according to a format specifier and returns the string as a func NewSoftErrorf(format string, a ...any) error { return NewSoftError(fmt.Errorf(format, a...)) } // NewSoftError creates a SoftError from the given error func NewSoftError(err error) error { return errors.Join(SoftError, err) } // Provider defines the interface DNS providers should implement. type Provider interface { Records(ctx context.Context) ([]*endpoint.Endpoint, error) ApplyChanges(ctx context.Context, changes *plan.Changes) error // AdjustEndpoints canonicalizes a set of candidate endpoints. // It is called with a set of candidate endpoints obtained from the various sources. // It returns a set modified as required by the provider. The provider is responsible for // adding, removing, and modifying the ProviderSpecific properties to match // the endpoints that the provider returns in `Records` so that the change plan will not have // unnecessary (potentially failing) changes. It may also modify other fields, add, or remove // Endpoints. It is permitted to modify the supplied endpoints. AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) GetDomainFilter() endpoint.DomainFilterInterface } type BaseProvider struct{} func (b BaseProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return endpoints, nil } func (b BaseProvider) GetDomainFilter() endpoint.DomainFilterInterface { return &endpoint.DomainFilter{} } type contextKey struct { name string } func (k *contextKey) String() string { return "provider context value " + k.name } // RecordsContextKey is a context key. It can be used during ApplyChanges // to access previously cached records. The associated value will be of // type []*endpoint.Endpoint. var RecordsContextKey = &contextKey{"records"} // EnsureTrailingDot ensures that the hostname receives a trailing dot if it hasn't already. func EnsureTrailingDot(hostname string) string { if net.ParseIP(hostname) != nil { return hostname } return strings.TrimSuffix(hostname, ".") + "." } // Difference tells which entries need to be respectively // added, removed, or left untouched for "current" to be transformed to "desired" func Difference(current, desired []string) ([]string, []string, []string) { add, remove, leave := []string{}, []string{}, []string{} index := make(map[string]struct{}, len(current)) for _, x := range current { index[x] = struct{}{} } for _, x := range desired { if _, found := index[x]; found { leave = append(leave, x) delete(index, x) } else { add = append(add, x) delete(index, x) } } for x := range index { remove = append(remove, x) } return add, remove, leave } ================================================ FILE: provider/provider_test.go ================================================ /* Copyright 2017 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 provider import ( "io" "os" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) func TestMain(m *testing.M) { log.SetOutput(io.Discard) os.Exit(m.Run()) } func TestEnsureTrailingDot(t *testing.T) { for _, tc := range []struct { input, expected string }{ {"example.org", "example.org."}, {"example.org.", "example.org."}, {"8.8.8.8", "8.8.8.8"}, } { output := EnsureTrailingDot(tc.input) if output != tc.expected { t.Errorf("expected %s, got %s", tc.expected, output) } } } func TestDifference(t *testing.T) { current := []string{"foo", "bar"} desired := []string{"bar", "baz"} add, remove, leave := Difference(current, desired) assert.Equal(t, []string{"baz"}, add) assert.Equal(t, []string{"foo"}, remove) assert.Equal(t, []string{"bar"}, leave) } ================================================ FILE: provider/recordfilter.go ================================================ /* Copyright 2017 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 provider // SupportedRecordType returns true only for supported record types. // Currently A, AAAA, CNAME, SRV, TXT and NS record types are supported. func SupportedRecordType(recordType string) bool { switch recordType { case "A", "AAAA", "CNAME", "SRV", "TXT", "NS": return true default: return false } } ================================================ FILE: provider/recordfilter_test.go ================================================ /* Copyright 2017 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 provider import "testing" func TestRecordTypeFilter(t *testing.T) { records := []struct { rtype string expect bool }{ { "A", true, }, { "AAAA", true, }, { "CNAME", true, }, { "TXT", true, }, { "MX", false, }, } for _, r := range records { got := SupportedRecordType(r.rtype) if r.expect != got { t.Errorf("wrong record type %s: expect %v, but got %v", r.rtype, r.expect, got) } } } ================================================ FILE: provider/rfc2136/rfc2136.go ================================================ /* Copyright 2017 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 rfc2136 import ( "context" "crypto/tls" "errors" "fmt" "math/rand" "net" "sort" "strconv" "strings" "sync" "time" "github.com/bodgit/tsig" "github.com/bodgit/tsig/gss" "github.com/miekg/dns" "sigs.k8s.io/external-dns/pkg/apis/externaldns" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/tlsutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( // maximum time DNS client can be off from server for an update to succeed clockSkew = 300 ) // rfc2136 provider type type rfc2136Provider struct { provider.BaseProvider nameservers []string zoneNames []string tsigKeyName string tsigSecret string tsigSecretAlg string insecure bool axfr bool minTTL time.Duration batchChangeSize int tlsConfig TLSConfig createPTR bool // options specific to rfc3645 gss-tsig support gssTsig bool krb5Username string krb5Password string krb5Realm string // only consider hosted zones managing domains ending in this suffix domainFilter *endpoint.DomainFilter dryRun bool actions rfc2136Actions // Counter for load balancing, and error handling counter int mu sync.Mutex // Mutex for thread-safe counter // Load balancing strategy "round-robin", "random", or "disabled" loadBalancingStrategy string // Random number generator for random load balancing randGen *rand.Rand // Last error encountered lastErr error } // TLSConfig is comprised of the TLS-related fields necessary if we are using DNS over TLS type TLSConfig struct { UseTLS bool SkipTLSVerify bool CAFilePath string ClientCertFilePath string ClientCertKeyFilePath string } // Map of supported TSIG algorithms var tsigAlgs = map[string]string{ "hmac-sha1": dns.HmacSHA1, "hmac-sha224": dns.HmacSHA224, "hmac-sha256": dns.HmacSHA256, "hmac-sha384": dns.HmacSHA384, "hmac-sha512": dns.HmacSHA512, } type rfc2136Actions interface { SendMessage(msg *dns.Msg) error IncomeTransfer(m *dns.Msg, nameserver string) (env chan *dns.Envelope, err error) } // New creates an RFC2136 provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { tlsConfig := TLSConfig{ UseTLS: cfg.RFC2136UseTLS, SkipTLSVerify: cfg.RFC2136SkipTLSVerify, CAFilePath: cfg.TLSCA, ClientCertFilePath: cfg.TLSClientCert, ClientCertKeyFilePath: cfg.TLSClientCertKey, } return newProvider(cfg.RFC2136Host, cfg.RFC2136Port, cfg.RFC2136Zone, cfg.RFC2136Insecure, cfg.RFC2136TSIGKeyName, cfg.RFC2136TSIGSecret, cfg.RFC2136TSIGSecretAlg, cfg.RFC2136TAXFR, domainFilter, cfg.DryRun, cfg.RFC2136MinTTL, cfg.RFC2136CreatePTR, cfg.RFC2136GSSTSIG, cfg.RFC2136KerberosUsername, cfg.RFC2136KerberosPassword, cfg.RFC2136KerberosRealm, cfg.RFC2136BatchChangeSize, tlsConfig, cfg.RFC2136LoadBalancingStrategy, nil) } // newProvider is a factory function for OpenStack rfc2136 providers func newProvider(hosts []string, port int, zoneNames []string, insecure bool, keyName, secret, secretAlg string, axfr bool, domainFilter *endpoint.DomainFilter, dryRun bool, minTTL time.Duration, createPTR, gssTsig bool, krb5Username, krb5Password, krb5Realm string, batchChangeSize int, tlsConfig TLSConfig, loadBalancingStrategy string, actions rfc2136Actions) (provider.Provider, error) { secretAlgChecked, ok := tsigAlgs[secretAlg] if !ok && !insecure && !gssTsig { return nil, fmt.Errorf("%s is not supported TSIG algorithm", secretAlg) } // Set zone to root if no set if len(zoneNames) == 0 { zoneNames = append(zoneNames, ".") } // Sort zones sort.Slice(zoneNames, func(i, j int) bool { return len(strings.Split(zoneNames[i], ".")) > len(strings.Split(zoneNames[j], ".")) }) var nameservers []string for _, host := range hosts { host = net.JoinHostPort(host, strconv.Itoa(port)) nameservers = append(nameservers, host) } r := &rfc2136Provider{ nameservers: nameservers, zoneNames: zoneNames, insecure: insecure, gssTsig: gssTsig, createPTR: createPTR, krb5Username: krb5Username, krb5Password: krb5Password, krb5Realm: strings.ToUpper(krb5Realm), domainFilter: domainFilter, dryRun: dryRun, axfr: axfr, minTTL: minTTL, batchChangeSize: batchChangeSize, tlsConfig: tlsConfig, loadBalancingStrategy: loadBalancingStrategy, randGen: rand.New(rand.NewSource(time.Now().UnixNano())), counter: 0, lastErr: nil, } if actions != nil { r.actions = actions } else { r.actions = r } if !insecure { r.tsigKeyName = dns.Fqdn(keyName) r.tsigSecret = secret r.tsigSecretAlg = secretAlgChecked } log.Infof("Configured RFC2136 with zones '%v' and nameservers '%v'", r.zoneNames, hosts) return r, nil } // KeyData will return TKEY name and TSIG handle to use for followon actions with a secure connection func (r *rfc2136Provider) KeyData(nameserver string) (string, *gss.Client, error) { handle, err := gss.NewClient(new(dns.Client)) if err != nil { return "", handle, err } keyName, _, err := handle.NegotiateContextWithCredentials(nameserver, r.krb5Realm, r.krb5Username, r.krb5Password) if err != nil { return keyName, handle, err } return keyName, handle, nil } // Records returns the list of records. func (r *rfc2136Provider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { rrs, err := r.List() if err != nil { return nil, err } var eps []*endpoint.Endpoint OuterLoop: for _, rr := range rrs { log.Debugf("Record=%s", rr) if rr.Header().Class != dns.ClassINET { continue } rrFqdn := rr.Header().Name rrTTL := endpoint.TTL(rr.Header().Ttl) var rrType string var rrValues []string switch rr.Header().Rrtype { case dns.TypeCNAME: rrValues = []string{rr.(*dns.CNAME).Target} rrType = "CNAME" case dns.TypeA: rrValues = []string{rr.(*dns.A).A.String()} rrType = "A" case dns.TypeAAAA: rrValues = []string{rr.(*dns.AAAA).AAAA.String()} rrType = "AAAA" case dns.TypeTXT: rrValues = (rr.(*dns.TXT).Txt) rrType = "TXT" case dns.TypeNS: rrValues = []string{rr.(*dns.NS).Ns} rrType = "NS" case dns.TypePTR: rrValues = []string{rr.(*dns.PTR).Ptr} rrType = "PTR" default: continue // Unhandled record type } for idx, existingEndpoint := range eps { if existingEndpoint.DNSName == strings.TrimSuffix(rrFqdn, ".") && existingEndpoint.RecordType == rrType { eps[idx].Targets = append(eps[idx].Targets, rrValues...) continue OuterLoop } } ep := endpoint.NewEndpointWithTTL( rrFqdn, rrType, rrTTL, rrValues..., ) eps = append(eps, ep) } return eps, nil } func (r *rfc2136Provider) IncomeTransfer(m *dns.Msg, nameserver string) (chan *dns.Envelope, error) { t := new(dns.Transfer) if !r.insecure && !r.gssTsig { t.TsigSecret = map[string]string{r.tsigKeyName: r.tsigSecret} } c, err := makeClient(r, nameserver) if err != nil { return nil, fmt.Errorf("error setting up TLS: %w", err) } conn, err := c.Dial(nameserver) if err != nil { return nil, fmt.Errorf("failed to connect for transfer: %w", err) } t.Conn = conn return t.In(m, nameserver) } func (r *rfc2136Provider) List() ([]dns.RR, error) { if !r.axfr { log.Debug("axfr is disabled") return make([]dns.RR, 0), nil } records := make([]dns.RR, 0) for _, zone := range r.zoneNames { log.Debugf("Fetching records for '%q'", zone) m := new(dns.Msg) m.SetAxfr(dns.Fqdn(zone)) if !r.insecure && !r.gssTsig { m.SetTsig(r.tsigKeyName, r.tsigSecretAlg, clockSkew, time.Now().Unix()) } var lastErr error for i := 0; i < len(r.nameservers); i++ { nameserver := r.getNextNameserver() log.Debugf("Fetching records from nameserver: %s", nameserver) env, err := r.actions.IncomeTransfer(m, nameserver) if err != nil { lastErr = fmt.Errorf("failed to fetch records via AXFR: %w", err) r.lastErr = lastErr continue } for e := range env { if e.Error != nil { if errors.Is(e.Error, dns.ErrSoa) { log.Error("AXFR error: unexpected response received from the server") } else { log.Errorf("AXFR error: %v", e.Error) } continue } records = append(records, e.RR...) } // If records were fetched successfully, break out of the loop if len(records) > 0 { break } } if lastErr != nil { r.lastErr = lastErr return nil, provider.NewSoftError(lastErr) } } return records, nil } func (r *rfc2136Provider) AddReverseRecord(ip string, hostname string) error { changes := r.GenerateReverseRecord(ip, hostname) return r.ApplyChanges(context.Background(), &plan.Changes{Create: changes}) } func (r *rfc2136Provider) RemoveReverseRecord(ip string, hostname string) error { changes := r.GenerateReverseRecord(ip, hostname) return r.ApplyChanges(context.Background(), &plan.Changes{Delete: changes}) } func (r *rfc2136Provider) GenerateReverseRecord(ip string, hostname string) []*endpoint.Endpoint { // Generate PTR notation record starting from the IP address var records []*endpoint.Endpoint log.Debugf("Reverse zone is: %s %s", ip, dns.Fqdn(ip)) reverseAddress, _ := dns.ReverseAddr(ip) // PTR records = append(records, &endpoint.Endpoint{ DNSName: reverseAddress[:len(reverseAddress)-1], RecordType: "PTR", Targets: endpoint.Targets{hostname}, }) return records } // ApplyChanges applies a given set of changes in a given zone. func (r *rfc2136Provider) ApplyChanges(_ context.Context, changes *plan.Changes) error { log.Debugf("ApplyChanges (Create: %d, UpdateOld: %d, UpdateNew: %d, Delete: %d)", len(changes.Create), len(changes.UpdateOld), len(changes.UpdateNew), len(changes.Delete)) var errs []error for c, chunk := range chunkBy(changes.Create, r.batchChangeSize) { log.Debugf("Processing batch %d of create changes", c) m := make(map[string]*dns.Msg) m["."] = new(dns.Msg) // Add the root zone for _, z := range r.zoneNames { z = dns.Fqdn(z) m[z] = new(dns.Msg) } for _, ep := range chunk { if !r.domainFilter.Match(ep.DNSName) { log.Debugf("Skipping record %s because it was filtered out by the specified --domain-filter", ep.DNSName) continue } zone := findMsgZone(ep, r.zoneNames) m[zone].SetUpdate(zone) r.AddRecord(m[zone], ep) if r.createPTR && (ep.RecordType == "A" || ep.RecordType == "AAAA") { r.AddReverseRecord(ep.Targets[0], ep.DNSName) } } // only send if there are records available for _, z := range m { if len(z.Ns) > 0 { if err := r.actions.SendMessage(z); err != nil { log.Errorf("RFC2136 create record failed: %v", err) errs = append(errs, err) continue } } } } for c, chunk := range chunkBy(changes.UpdateNew, r.batchChangeSize) { log.Debugf("Processing batch %d of update changes", c) m := make(map[string]*dns.Msg) m["."] = new(dns.Msg) // Add the root zone for _, z := range r.zoneNames { z = dns.Fqdn(z) m[z] = new(dns.Msg) } for i, ep := range chunk { if !r.domainFilter.Match(ep.DNSName) { log.Debugf("Skipping record %s because it was filtered out by the specified --domain-filter", ep.DNSName) continue } zone := findMsgZone(ep, r.zoneNames) m[zone].SetUpdate(zone) // calculate corresponding index in the unsplitted UpdateOld for current endpoint ep in chunk j := (c * r.batchChangeSize) + i r.UpdateRecord(m[zone], changes.UpdateOld[j], ep) if r.createPTR && (ep.RecordType == "A" || ep.RecordType == "AAAA") { r.RemoveReverseRecord(changes.UpdateOld[j].Targets[0], ep.DNSName) r.AddReverseRecord(ep.Targets[0], ep.DNSName) } } // only send if there are records available for _, z := range m { if len(z.Ns) > 0 { if err := r.actions.SendMessage(z); err != nil { log.Errorf("RFC2136 update record failed: %v", err) errs = append(errs, err) continue } } } } for c, chunk := range chunkBy(changes.Delete, r.batchChangeSize) { log.Debugf("Processing batch %d of delete changes", c) m := make(map[string]*dns.Msg) m["."] = new(dns.Msg) // Add the root zone for _, z := range r.zoneNames { z = dns.Fqdn(z) m[z] = new(dns.Msg) } for _, ep := range chunk { if !r.domainFilter.Match(ep.DNSName) { log.Debugf("Skipping record %s because it was filtered out by the specified --domain-filter", ep.DNSName) continue } zone := findMsgZone(ep, r.zoneNames) m[zone].SetUpdate(zone) r.RemoveRecord(m[zone], ep) if r.createPTR && (ep.RecordType == "A" || ep.RecordType == "AAAA") { r.RemoveReverseRecord(ep.Targets[0], ep.DNSName) } } // only send if there are records available for _, z := range m { if len(z.Ns) > 0 { if err := r.actions.SendMessage(z); err != nil { log.Errorf("RFC2136 delete record failed: %v", err) errs = append(errs, err) continue } } } } if len(errs) > 0 { return provider.NewSoftErrorf("RFC2136 had errors in one or more of its batches: %v", errs) } return nil } func (r *rfc2136Provider) UpdateRecord(m *dns.Msg, oldEp *endpoint.Endpoint, newEp *endpoint.Endpoint) error { err := r.RemoveRecord(m, oldEp) if err != nil { return err } return r.AddRecord(m, newEp) } func (r *rfc2136Provider) AddRecord(m *dns.Msg, ep *endpoint.Endpoint) error { log.Debugf("AddRecord.ep=%s", ep) ttl := int64(r.minTTL.Seconds()) if ep.RecordTTL.IsConfigured() && int64(ep.RecordTTL) > ttl { ttl = int64(ep.RecordTTL) } for _, target := range ep.Targets { newRR := fmt.Sprintf("%s %d %s %s", ep.DNSName, ttl, ep.RecordType, target) log.Infof("Adding RR: %s", newRR) rr, err := dns.NewRR(newRR) if err != nil { return fmt.Errorf("failed to build RR: %w", err) } m.Insert([]dns.RR{rr}) } return nil } func (r *rfc2136Provider) RemoveRecord(m *dns.Msg, ep *endpoint.Endpoint) error { log.Debugf("RemoveRecord.ep=%s", ep) for _, target := range ep.Targets { newRR := fmt.Sprintf("%s %d %s %s", ep.DNSName, ep.RecordTTL, ep.RecordType, target) log.Infof("Removing RR: %s", newRR) rr, err := dns.NewRR(newRR) if err != nil { return fmt.Errorf("failed to build RR: %w", err) } m.Remove([]dns.RR{rr}) } return nil } func (r *rfc2136Provider) getNextNameserver() string { if len(r.nameservers) == 1 { return r.nameservers[0] } r.mu.Lock() defer r.mu.Unlock() if r.lastErr != nil { log.Warnf("Last operation failed for nameserver %s", r.nameservers[r.counter]) log.Warnf("Last operation error message: %v", r.lastErr) } var nameserver string switch r.loadBalancingStrategy { case "random": for { nameserver = r.nameservers[r.randGen.Intn(len(r.nameservers))] // Ensure that we don't get the same nameserver as the last one if nameserver != r.nameservers[r.counter] { break } } case "round-robin": nameserver = r.nameservers[r.counter] r.counter = (r.counter + 1) % len(r.nameservers) default: if r.lastErr != nil { r.counter = (r.counter + 1) % len(r.nameservers) nameserver = r.nameservers[r.counter] } else { nameserver = r.nameservers[r.counter] } } // Last error has been logged, reset it for the next operation r.lastErr = nil return nameserver } func (r *rfc2136Provider) SendMessage(msg *dns.Msg) error { if r.dryRun { log.Debugf("SendMessage.skipped") return nil } log.Debugf("SendMessage") var lastErr error for i := 0; i < len(r.nameservers); i++ { nameserver := r.getNextNameserver() log.Debugf("Sending message to nameserver: %s", nameserver) c, err := makeClient(r, nameserver) if err != nil { lastErr = fmt.Errorf("error setting up TLS: %w", err) r.lastErr = lastErr continue } if !r.insecure { if r.gssTsig { keyName, handle, err := r.KeyData(nameserver) if err != nil { lastErr = err r.lastErr = lastErr continue } defer handle.Close() defer handle.DeleteContext(keyName) c.TsigProvider = handle msg.SetTsig(keyName, tsig.GSS, clockSkew, time.Now().Unix()) } else { c.TsigProvider = tsig.HMAC{r.tsigKeyName: r.tsigSecret} msg.SetTsig(r.tsigKeyName, r.tsigSecretAlg, clockSkew, time.Now().Unix()) } } resp, _, err := c.Exchange(msg, nameserver) if err != nil { if resp != nil && resp.Rcode != dns.RcodeSuccess { log.Infof("error in dns.Client.Exchange: %s", err) lastErr = err r.lastErr = lastErr continue } log.Warnf("warn in dns.Client.Exchange: %s", err) lastErr = err r.lastErr = lastErr continue } if resp != nil && resp.Rcode != dns.RcodeSuccess { log.Infof("Bad dns.Client.Exchange response: %s", resp) lastErr = fmt.Errorf("bad return code: %s", dns.RcodeToString[resp.Rcode]) r.lastErr = lastErr continue } log.Debugf("SendMessage.success") return nil } r.lastErr = lastErr return provider.NewSoftError(lastErr) } func chunkBy(slice []*endpoint.Endpoint, chunkSize int) [][]*endpoint.Endpoint { var chunks [][]*endpoint.Endpoint for i := 0; i < len(slice); i += chunkSize { end := min(i+chunkSize, len(slice)) chunks = append(chunks, slice[i:end]) } return chunks } func findMsgZone(ep *endpoint.Endpoint, zoneNames []string) string { for _, zone := range zoneNames { if strings.HasSuffix(ep.DNSName, zone) { return dns.Fqdn(zone) } } log.Warnf("No available zone found for %s, set it to 'root'", ep.DNSName) return dns.Fqdn(".") } func makeClient(r *rfc2136Provider, nameserver string) (*dns.Client, error) { c := new(dns.Client) // Remove port from nameserver nameserver = strings.Split(nameserver, ":")[0] if r.tlsConfig.UseTLS { log.Debug("RFC2136 Connecting via TLS") c.Net = "tcp-tls" tlsConfig, err := tlsutils.NewTLSConfig( r.tlsConfig.ClientCertFilePath, r.tlsConfig.ClientCertKeyFilePath, r.tlsConfig.CAFilePath, nameserver, // Use the current nameserver r.tlsConfig.SkipTLSVerify, // Per RFC9103 tls.VersionTLS13, ) if err != nil { return nil, err } if tlsConfig.NextProtos == nil { // Per RFC9103 tlsConfig.NextProtos = []string{"dot"} } c.TLSConfig = tlsConfig } else { c.Net = "tcp" } return c, nil } ================================================ FILE: provider/rfc2136/rfc2136_test.go ================================================ /* Copyright 2017 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 rfc2136 import ( "crypto/tls" "fmt" "math/rand" "os" "regexp" "sort" "strconv" "strings" "testing" "time" "github.com/miekg/dns" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) type rfc2136Stub struct { output []*dns.Envelope updateMsgs []*dns.Msg createMsgs []*dns.Msg nameservers []string counter int randGen *rand.Rand lastNameserver string loadBalancingStrategy string } func newStub() *rfc2136Stub { return &rfc2136Stub{ output: make([]*dns.Envelope, 0), updateMsgs: make([]*dns.Msg, 0), createMsgs: make([]*dns.Msg, 0), nameservers: []string{""}, randGen: rand.New(rand.NewSource(time.Now().UnixNano())), loadBalancingStrategy: "round-robin", } } func newStubLB(strategy string, nameservers []string) *rfc2136Stub { return &rfc2136Stub{ output: make([]*dns.Envelope, 0), updateMsgs: make([]*dns.Msg, 0), createMsgs: make([]*dns.Msg, 0), nameservers: nameservers, randGen: rand.New(rand.NewSource(time.Now().UnixNano())), loadBalancingStrategy: strategy, } } func (r *rfc2136Stub) getNextNameserver() string { if len(r.nameservers) == 1 { return r.nameservers[0] } switch r.loadBalancingStrategy { case "random": return r.nameservers[r.randGen.Intn(len(r.nameservers))] case "round-robin": nameserver := r.nameservers[r.counter] r.counter = (r.counter + 1) % len(r.nameservers) return nameserver default: return r.nameservers[0] } } func getSortedChanges(msgs []*dns.Msg) []string { r := []string{} for _, d := range msgs { // only care about section after the ZONE SECTION: as the id: needs stripped out in order to sort and grantee the order when sorting r = append(r, strings.Split(d.String(), "ZONE SECTION:")[1]) } sort.Strings(r) return r } func (r *rfc2136Stub) SendMessage(msg *dns.Msg) error { r.lastNameserver = r.getNextNameserver() log.Info("Sending message to nameserver: ", r.lastNameserver) zone := extractZoneFromMessage(msg.String()) // Make sure the zone starts with . to make sure HasSuffix does not match forbar.com for zone bar.com if !strings.HasPrefix(zone, ".") { zone = "." + zone } log.Infof("zone=%s", zone) lines := extractUpdateSectionFromMessage(msg) for _, line := range lines { // break at first empty line if len(strings.TrimSpace(line)) == 0 { break } line = strings.ReplaceAll(line, "\t", " ") log.Info(line) record := strings.Split(line, " ")[0] if !strings.HasSuffix(record, zone) { err := fmt.Errorf("Message contains updates outside of it's zone. zone=%v record=%v", zone, record) log.Error(err) return err } if strings.Contains(line, " NONE ") { r.updateMsgs = append(r.updateMsgs, msg) } else if strings.Contains(line, " IN ") { r.createMsgs = append(r.createMsgs, msg) } } return nil } func (r *rfc2136Stub) setOutput(output []string) error { r.output = make([]*dns.Envelope, len(output)) for i, e := range output { rr, err := dns.NewRR(e) if err != nil { return err } r.output[i] = &dns.Envelope{ RR: []dns.RR{rr}, } } return nil } func (r *rfc2136Stub) IncomeTransfer(m *dns.Msg, _ string) (chan *dns.Envelope, error) { outChan := make(chan *dns.Envelope) go func() { for _, e := range r.output { var responseEnvelope *dns.Envelope for _, record := range e.RR { for _, q := range m.Question { if strings.HasSuffix(record.Header().Name, q.Name) { if responseEnvelope == nil { responseEnvelope = &dns.Envelope{} } responseEnvelope.RR = append(responseEnvelope.RR, record) break } } } if responseEnvelope == nil { continue } outChan <- responseEnvelope } close(outChan) }() return outChan, nil } func createRfc2136StubProvider(stub *rfc2136Stub, zoneNames ...string) (provider.Provider, error) { tlsConfig := TLSConfig{ UseTLS: false, SkipTLSVerify: false, CAFilePath: "", ClientCertFilePath: "", ClientCertKeyFilePath: "", } return newProvider([]string{""}, 0, zoneNames, false, "key", "secret", "hmac-sha512", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, "", "", "", 50, tlsConfig, "", stub) } func createRfc2136StubProviderWithHosts(stub *rfc2136Stub) (provider.Provider, error) { tlsConfig := TLSConfig{ UseTLS: false, SkipTLSVerify: false, CAFilePath: "", ClientCertFilePath: "", ClientCertKeyFilePath: "", } return newProvider([]string{"rfc2136-host1", "rfc2136-host2", "rfc2136-host3"}, 0, nil, false, "key", "secret", "hmac-sha512", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, "", "", "", 50, tlsConfig, "", stub) } func createRfc2136TLSStubProvider(stub *rfc2136Stub, tlsConfig TLSConfig) (provider.Provider, error) { return newProvider([]string{"rfc2136-host"}, 0, nil, false, "key", "secret", "hmac-sha512", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, "", "", "", 50, tlsConfig, "", stub) } func createRfc2136TLSStubProviderWithHosts(stub *rfc2136Stub, tlsConfig TLSConfig) (provider.Provider, error) { return newProvider([]string{"rfc2136-host1", "rfc2136-host2"}, 0, nil, false, "key", "secret", "hmac-sha512", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, "", "", "", 50, tlsConfig, "", stub) } func createRfc2136StubProviderWithReverse(stub *rfc2136Stub) (provider.Provider, error) { tlsConfig := TLSConfig{ UseTLS: false, SkipTLSVerify: false, CAFilePath: "", ClientCertFilePath: "", ClientCertKeyFilePath: "", } zones := []string{"foo.com", "3.2.1.in-addr.arpa"} return newProvider([]string{""}, 0, zones, false, "key", "secret", "hmac-sha512", true, endpoint.NewDomainFilter(zones), false, 300*time.Second, true, false, "", "", "", 50, tlsConfig, "", stub) } func createRfc2136StubProviderWithZones(stub *rfc2136Stub) (provider.Provider, error) { tlsConfig := TLSConfig{ UseTLS: false, SkipTLSVerify: false, CAFilePath: "", ClientCertFilePath: "", ClientCertKeyFilePath: "", } zones := []string{"foo.com", "foobar.com"} return newProvider([]string{""}, 0, zones, false, "key", "secret", "hmac-sha512", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, "", "", "", 50, tlsConfig, "", stub) } func createRfc2136StubProviderWithZonesFilters(stub *rfc2136Stub) (provider.Provider, error) { tlsConfig := TLSConfig{ UseTLS: false, SkipTLSVerify: false, CAFilePath: "", ClientCertFilePath: "", ClientCertKeyFilePath: "", } zones := []string{"foo.com", "foobar.com"} return newProvider([]string{""}, 0, zones, false, "key", "secret", "hmac-sha512", true, endpoint.NewDomainFilter(zones), false, 300*time.Second, false, false, "", "", "", 50, tlsConfig, "", stub) } func createRfc2136StubProviderWithStrategy(stub *rfc2136Stub, strategy string) (provider.Provider, error) { tlsConfig := TLSConfig{ UseTLS: false, SkipTLSVerify: false, CAFilePath: "", ClientCertFilePath: "", ClientCertKeyFilePath: "", } return newProvider([]string{"rfc2136-host1", "rfc2136-host2", "rfc2136-host3"}, 0, nil, false, "key", "secret", "hmac-sha512", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, "", "", "", 50, tlsConfig, strategy, stub) } func createRfc2136StubProviderWithBatchChangeSize(stub *rfc2136Stub, batchChangeSize int) (provider.Provider, error) { tlsConfig := TLSConfig{ UseTLS: false, SkipTLSVerify: false, CAFilePath: "", ClientCertFilePath: "", ClientCertKeyFilePath: "", } return newProvider([]string{""}, 0, nil, false, "key", "secret", "hmac-sha512", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, "", "", "", batchChangeSize, tlsConfig, "", stub) } func extractUpdateSectionFromMessage(msg fmt.Stringer) []string { const searchPattern = "UPDATE SECTION:" updateSectionOffset := strings.Index(msg.String(), searchPattern) return strings.Split(strings.TrimSpace(msg.String()[updateSectionOffset+len(searchPattern):]), "\n") } func extractZoneFromMessage(msg string) string { re := regexp.MustCompile(`ZONE SECTION:\n;(?P[\.,\-,\w,\d]+)\t`) matches := re.FindStringSubmatch(msg) return matches[re.SubexpIndex("ZONE")] } // TestRfc2136GetRecordsMultipleTargets simulates a single record with multiple targets. func TestRfc2136GetRecordsMultipleTargets(t *testing.T) { stub := newStub() err := stub.setOutput([]string{ "foo.com 3600 IN A 1.1.1.1", "foo.com 3600 IN A 2.2.2.2", }) assert.NoError(t, err) provider, err := createRfc2136StubProvider(stub) assert.NoError(t, err) recs, err := provider.Records(t.Context()) assert.NoError(t, err) assert.Len(t, recs, 1, "expected single record") assert.Equal(t, "foo.com", recs[0].DNSName) assert.Len(t, recs[0].Targets, 2, "expected two targets") assert.True(t, recs[0].Targets[0] == "1.1.1.1" || recs[0].Targets[1] == "1.1.1.1") // ignore order assert.True(t, recs[0].Targets[0] == "2.2.2.2" || recs[0].Targets[1] == "2.2.2.2") // ignore order assert.Equal(t, "A", recs[0].RecordType) assert.Equal(t, recs[0].RecordTTL, endpoint.TTL(3600)) assert.Empty(t, recs[0].Labels, "expected no labels") assert.Empty(t, recs[0].ProviderSpecific, "expected no provider specific config") } func TestRfc2136PTRCreation(t *testing.T) { stub := newStub() provider, err := createRfc2136StubProviderWithReverse(stub) assert.NoError(t, err) err = provider.ApplyChanges(t.Context(), &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "demo.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, }, }, }) assert.NoError(t, err) assert.Len(t, stub.createMsgs, 2, "expected two records, one A and one PTR") createMsgs := getSortedChanges(stub.createMsgs) assert.Contains(t, strings.Join(strings.Fields(createMsgs[0]), " "), "4.3.2.1.in-addr.arpa. 300 IN PTR demo.foo.com.", "excpeted a PTR record") assert.Contains(t, strings.Join(strings.Fields(createMsgs[1]), " "), "demo.foo.com. 300 IN A 1.2.3.4", "expected an A record") } func TestRfc2136TLSConfig(t *testing.T) { stub := newStub() caFile, err := os.CreateTemp(t.TempDir(), "rfc2136-test-XXXXXXXX.crt") require.NoError(t, err) defer os.Remove(caFile.Name()) _, err = caFile.Write([]byte( `-----BEGIN CERTIFICATE----- MIH+MIGxAhR2n1aQk0ONrQ8QQfa6GCzFWLmTXTAFBgMrZXAwITELMAkGA1UEBhMC REUxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMzEwMjQwNzI5NDNaGA8yMTIzMDkz MDA3Mjk0M1owITELMAkGA1UEBhMCREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUG AytlcAMhAA1FzGJXuQdOpKv02SEl7SIA8SP8RVRI0QTi1bUFiFBLMAUGAytlcANB ADiCKRUGDMyafSSYhl0KXoiXrFOxvhrGM5l15L4q82JM5Qb8wv0gNrnbGTZlInuv ouB5ZN+05DzKCQhBekMnygQ= -----END CERTIFICATE----- `)) tlsConfig := TLSConfig{ UseTLS: true, SkipTLSVerify: false, CAFilePath: caFile.Name(), ClientCertFilePath: "", ClientCertKeyFilePath: "", } provider, err := createRfc2136TLSStubProvider(stub, tlsConfig) require.NoError(t, err) rawProvider := provider.(*rfc2136Provider) client, err := makeClient(rawProvider, rawProvider.nameservers[0]) require.NoError(t, err) assert.Equal(t, "tcp-tls", client.Net) assert.False(t, client.TLSConfig.InsecureSkipVerify) assert.Equal(t, "rfc2136-host", client.TLSConfig.ServerName) assert.Equal(t, uint16(tls.VersionTLS13), client.TLSConfig.MinVersion) assert.Equal(t, []string{"dot"}, client.TLSConfig.NextProtos) } func TestRfc2136TLSConfigWithMultiHosts(t *testing.T) { stub := newStub() caFile, err := os.CreateTemp(t.TempDir(), "rfc2136-test-XXXXXXXX.crt") assert.NoError(t, err) defer os.Remove(caFile.Name()) _, err = caFile.Write([]byte( `-----BEGIN CERTIFICATE----- MIH+MIGxAhR2n1aQk0ONrQ8QQfa6GCzFWLmTXTAFBgMrZXAwITELMAkGA1UEBhMC REUxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMzEwMjQwNzI5NDNaGA8yMTIzMDkz MDA3Mjk0M1owITELMAkGA1UEBhMCREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUG AytlcAMhAA1FzGJXuQdOpKv02SEl7SIA8SP8RVRI0QTi1bUFiFBLMAUGAytlcANB ADiCKRUGDMyafSSYhl0KXoiXrFOxvhrGM5l15L4q82JM5Qb8wv0gNrnbGTZlInuv ouB5ZN+05DzKCQhBekMnygQ= -----END CERTIFICATE----- `)) tlsConfig := TLSConfig{ UseTLS: true, SkipTLSVerify: false, CAFilePath: caFile.Name(), ClientCertFilePath: "", ClientCertKeyFilePath: "", } provider, err := createRfc2136TLSStubProviderWithHosts(stub, tlsConfig) assert.NoError(t, err) rawProvider := provider.(*rfc2136Provider) for _, ns := range rawProvider.nameservers { client, err := makeClient(rawProvider, ns) assert.NoError(t, err) // strip port from ns ns = strings.Split(ns, ":")[0] assert.Equal(t, "tcp-tls", client.Net) assert.False(t, client.TLSConfig.InsecureSkipVerify) assert.Equal(t, ns, client.TLSConfig.ServerName) assert.Equal(t, uint16(tls.VersionTLS13), client.TLSConfig.MinVersion) assert.Equal(t, []string{"dot"}, client.TLSConfig.NextProtos) } } func TestRfc2136TLSConfigNoVerify(t *testing.T) { stub := newStub() caFile, err := os.CreateTemp(t.TempDir(), "rfc2136-test-XXXXXXXX.crt") assert.NoError(t, err) defer os.Remove(caFile.Name()) _, err = caFile.Write([]byte( `-----BEGIN CERTIFICATE----- MIH+MIGxAhR2n1aQk0ONrQ8QQfa6GCzFWLmTXTAFBgMrZXAwITELMAkGA1UEBhMC REUxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMzEwMjQwNzI5NDNaGA8yMTIzMDkz MDA3Mjk0M1owITELMAkGA1UEBhMCREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUG AytlcAMhAA1FzGJXuQdOpKv02SEl7SIA8SP8RVRI0QTi1bUFiFBLMAUGAytlcANB ADiCKRUGDMyafSSYhl0KXoiXrFOxvhrGM5l15L4q82JM5Qb8wv0gNrnbGTZlInuv ouB5ZN+05DzKCQhBekMnygQ= -----END CERTIFICATE----- `)) tlsConfig := TLSConfig{ UseTLS: true, SkipTLSVerify: true, CAFilePath: caFile.Name(), ClientCertFilePath: "", ClientCertKeyFilePath: "", } provider, err := createRfc2136TLSStubProvider(stub, tlsConfig) assert.NoError(t, err) rawProvider := provider.(*rfc2136Provider) client, err := makeClient(rawProvider, rawProvider.nameservers[0]) assert.NoError(t, err) assert.Equal(t, "tcp-tls", client.Net) assert.True(t, client.TLSConfig.InsecureSkipVerify) assert.Equal(t, "rfc2136-host", client.TLSConfig.ServerName) assert.Equal(t, uint16(tls.VersionTLS13), client.TLSConfig.MinVersion) assert.Equal(t, []string{"dot"}, client.TLSConfig.NextProtos) } func TestRfc2136TLSConfigClientAuth(t *testing.T) { stub := newStub() caFile, err := os.CreateTemp(t.TempDir(), "rfc2136-test-XXXXXXXX.crt") assert.NoError(t, err) defer os.Remove(caFile.Name()) _, err = caFile.Write([]byte( `-----BEGIN CERTIFICATE----- MIH+MIGxAhR2n1aQk0ONrQ8QQfa6GCzFWLmTXTAFBgMrZXAwITELMAkGA1UEBhMC REUxEjAQBgNVBAMMCWxvY2FsaG9zdDAgFw0yMzEwMjQwNzI5NDNaGA8yMTIzMDkz MDA3Mjk0M1owITELMAkGA1UEBhMCREUxEjAQBgNVBAMMCWxvY2FsaG9zdDAqMAUG AytlcAMhAA1FzGJXuQdOpKv02SEl7SIA8SP8RVRI0QTi1bUFiFBLMAUGAytlcANB ADiCKRUGDMyafSSYhl0KXoiXrFOxvhrGM5l15L4q82JM5Qb8wv0gNrnbGTZlInuv ouB5ZN+05DzKCQhBekMnygQ= -----END CERTIFICATE----- `)) certFile, err := os.CreateTemp(t.TempDir(), "rfc2136-test-XXXXXXXX-client.crt") assert.NoError(t, err) defer os.Remove(certFile.Name()) _, err = certFile.Write([]byte( `-----BEGIN CERTIFICATE----- MIIBfDCCAQICFANNDjPVDMTPm63C0jZ9M3H5I7GJMAoGCCqGSM49BAMCMCExCzAJ BgNVBAYTAkRFMRIwEAYDVQQDDAlsb2NhbGhvc3QwIBcNMjMxMDI0MDcyMTU1WhgP MjEyMzA5MzAwNzIxNTVaMCExCzAJBgNVBAYTAkRFMRIwEAYDVQQDDAlsb2NhbGhv c3QwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQj7rjkeUEvjBT++IBMnIWgmI9VIjFx 4VUGFmzPEawOckdnKW4fBdePiItsgePDVK4Oys5bzfSDhl6aAPCe16pwvljB7yIm xLJ+ytWk7OV/s10cmlaczrEtNeUjV1X9MTMwCgYIKoZIzj0EAwIDaAAwZQIwcZl8 TrwwsyX3A0enXB1ih+nruF8Q9f9Rmm2pNcbEv24QIW/P2HGQm9qfx4lrYa7hAjEA goRP/fRfTTTLwLg8UBpUAmALX8A8HBSBaUlTTQcaImbcwU4DRSbv5JEA8tM1mWrA -----END CERTIFICATE----- `)) keyFile, err := os.CreateTemp(t.TempDir(), "rfc2136-test-XXXXXXXX-client.key") assert.NoError(t, err) defer os.Remove(keyFile.Name()) _, err = keyFile.Write([]byte( `-----BEGIN PRIVATE KEY----- MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDD5B+aPE+TuHCvW1f7L U8jEPVXHv1fvCR8uBSsf1qdPo929XGpt5y5QfIGdW3NUeHWhZANiAAQj7rjkeUEv jBT++IBMnIWgmI9VIjFx4VUGFmzPEawOckdnKW4fBdePiItsgePDVK4Oys5bzfSD hl6aAPCe16pwvljB7yImxLJ+ytWk7OV/s10cmlaczrEtNeUjV1X9MTM= -----END PRIVATE KEY----- `)) tlsConfig := TLSConfig{ UseTLS: true, SkipTLSVerify: false, CAFilePath: caFile.Name(), ClientCertFilePath: certFile.Name(), ClientCertKeyFilePath: keyFile.Name(), } provider, err := createRfc2136TLSStubProvider(stub, tlsConfig) log.Infof("provider, err is: %s", err) assert.NoError(t, err) rawProvider := provider.(*rfc2136Provider) client, err := makeClient(rawProvider, rawProvider.nameservers[0]) log.Infof("client, err is: %v", client) log.Infof("client, err is: %s", err) assert.NoError(t, err) assert.Equal(t, "tcp-tls", client.Net) assert.False(t, client.TLSConfig.InsecureSkipVerify) assert.Equal(t, "rfc2136-host", client.TLSConfig.ServerName) assert.Equal(t, uint16(tls.VersionTLS13), client.TLSConfig.MinVersion) assert.Equal(t, []string{"dot"}, client.TLSConfig.NextProtos) } func TestRfc2136GetRecords(t *testing.T) { stub := newStub() err := stub.setOutput([]string{ "v4.barfoo.com 3600 TXT test1", "v1.foo.com 3600 TXT test2", "v2.bar.com 3600 A 8.8.8.8", "v3.bar.com 3600 TXT bbbb", "v2.foo.com 3600 CNAME cccc", "v1.foobar.com 3600 TXT dddd", }) assert.NoError(t, err) provider, err := createRfc2136StubProvider(stub, "barfoo.com", "foo.com", "bar.com", "foobar.com") assert.NoError(t, err) recs, err := provider.Records(t.Context()) assert.NoError(t, err) assert.Len(t, recs, 6) assert.True(t, contains(recs, "v1.foo.com")) assert.True(t, contains(recs, "v2.bar.com")) assert.True(t, contains(recs, "v2.foo.com")) } // Make sure the test version of SendMessage raises an error // if a zone update ever contains records outside of its zone // as the TestRfc2136ApplyChanges tests all assume this func TestRfc2136SendMessage(t *testing.T) { stub := newStub() m := new(dns.Msg) m.SetUpdate("foo.com.") rr, err := dns.NewRR(fmt.Sprintf("%s %d %s %s", "v1.foo.com.", 0, "A", "1.2.3.4")) m.Insert([]dns.RR{rr}) err = stub.SendMessage(m) assert.NoError(t, err) rr, err = dns.NewRR(fmt.Sprintf("%s %d %s %s", "v1.bar.com.", 0, "A", "1.2.3.4")) m.Insert([]dns.RR{rr}) err = stub.SendMessage(m) assert.Error(t, err) m.SetUpdate(".") err = stub.SendMessage(m) assert.NoError(t, err) } // These tests are use the . root zone with no filters func TestRfc2136ApplyChanges(t *testing.T) { stub := newStub() provider, err := createRfc2136StubProvider(stub) assert.NoError(t, err) p := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, RecordTTL: endpoint.TTL(400), }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{"boom"}, }, { DNSName: "ns.foobar.com", RecordType: "NS", Targets: []string{"boom"}, }, }, Delete: []*endpoint.Endpoint{ { DNSName: "v2.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, }, { DNSName: "v2.foobar.com", RecordType: "TXT", Targets: []string{"boom2"}, }, }, } err = provider.ApplyChanges(t.Context(), p) assert.NoError(t, err) assert.Len(t, stub.createMsgs, 3) assert.Contains(t, stub.createMsgs[0].String(), "v1.foo.com") assert.Contains(t, stub.createMsgs[0].String(), "1.2.3.4") assert.Contains(t, stub.createMsgs[1].String(), "v1.foobar.com") assert.Contains(t, stub.createMsgs[1].String(), "boom") assert.Contains(t, stub.createMsgs[2].String(), "ns.foobar.com") assert.Contains(t, stub.createMsgs[2].String(), "boom") assert.Len(t, stub.updateMsgs, 2) assert.Contains(t, stub.updateMsgs[0].String(), "v2.foo.com") assert.Contains(t, stub.updateMsgs[1].String(), "v2.foobar.com") } // These tests all use the foo.com and foobar.com zones with no filters // createMsgs and updateMsgs need sorted when are used func TestRfc2136ApplyChangesWithZones(t *testing.T) { stub := newStub() provider, err := createRfc2136StubProviderWithZones(stub) assert.NoError(t, err) p := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, RecordTTL: endpoint.TTL(400), }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{"boom"}, }, { DNSName: "ns.foobar.com", RecordType: "NS", Targets: []string{"boom"}, }, }, Delete: []*endpoint.Endpoint{ { DNSName: "v2.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, }, { DNSName: "v2.foobar.com", RecordType: "TXT", Targets: []string{"boom2"}, }, }, } err = provider.ApplyChanges(t.Context(), p) assert.NoError(t, err) assert.Len(t, stub.createMsgs, 3) createMsgs := getSortedChanges(stub.createMsgs) assert.Len(t, createMsgs, 3) assert.Contains(t, createMsgs[0], "v1.foo.com") assert.Contains(t, createMsgs[0], "1.2.3.4") assert.Contains(t, createMsgs[1], "v1.foobar.com") assert.Contains(t, createMsgs[1], "boom") assert.Contains(t, createMsgs[2], "ns.foobar.com") assert.Contains(t, createMsgs[2], "boom") assert.Len(t, stub.updateMsgs, 2) updateMsgs := getSortedChanges(stub.updateMsgs) assert.Len(t, updateMsgs, 2) assert.Contains(t, updateMsgs[0], "v2.foo.com") assert.Contains(t, updateMsgs[1], "v2.foobar.com") } // These tests use the foo.com and foobar.com zones and with filters set to both zones // createMsgs and updateMsgs need sorted when are used func TestRfc2136ApplyChangesWithZonesFilters(t *testing.T) { stub := newStub() provider, err := createRfc2136StubProviderWithZonesFilters(stub) assert.NoError(t, err) p := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, RecordTTL: endpoint.TTL(400), }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{"boom"}, }, { DNSName: "ns.foobar.com", RecordType: "NS", Targets: []string{"boom"}, }, { DNSName: "filtered-out.foo.bar", RecordType: "A", Targets: []string{"1.2.3.4"}, RecordTTL: endpoint.TTL(400), }, }, Delete: []*endpoint.Endpoint{ { DNSName: "v2.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, }, { DNSName: "v2.foobar.com", RecordType: "TXT", Targets: []string{"boom2"}, }, }, } err = provider.ApplyChanges(t.Context(), p) assert.NoError(t, err) assert.Len(t, stub.createMsgs, 3) createMsgs := getSortedChanges(stub.createMsgs) assert.Len(t, createMsgs, 3) assert.Contains(t, createMsgs[0], "v1.foo.com") assert.Contains(t, createMsgs[0], "1.2.3.4") assert.Contains(t, createMsgs[1], "v1.foobar.com") assert.Contains(t, createMsgs[1], "boom") assert.Contains(t, createMsgs[2], "ns.foobar.com") assert.Contains(t, createMsgs[2], "boom") for _, s := range createMsgs { assert.NotContains(t, s, "filtered-out.foo.bar") } assert.Len(t, stub.updateMsgs, 2) updateMsgs := getSortedChanges(stub.updateMsgs) assert.Len(t, updateMsgs, 2) assert.Contains(t, updateMsgs[0], "v2.foo.com") assert.Contains(t, updateMsgs[1], "v2.foobar.com") } func TestRfc2136ApplyChangesWithDifferentTTLs(t *testing.T) { stub := newStub() provider, err := createRfc2136StubProvider(stub) assert.NoError(t, err) p := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{"2.1.1.1"}, RecordTTL: endpoint.TTL(400), }, { DNSName: "v2.foo.com", RecordType: "A", Targets: []string{"3.2.2.2"}, RecordTTL: endpoint.TTL(200), }, { DNSName: "v3.foo.com", RecordType: "A", Targets: []string{"4.3.3.3"}, }, }, } err = provider.ApplyChanges(t.Context(), p) assert.NoError(t, err) createRecords := extractUpdateSectionFromMessage(stub.createMsgs[0]) assert.Len(t, createRecords, 3) assert.Contains(t, createRecords[0], "v1.foo.com") assert.Contains(t, createRecords[0], "2.1.1.1") assert.Contains(t, createRecords[0], "400") assert.Contains(t, createRecords[1], "v2.foo.com") assert.Contains(t, createRecords[1], "3.2.2.2") assert.Contains(t, createRecords[1], "300") assert.Contains(t, createRecords[2], "v3.foo.com") assert.Contains(t, createRecords[2], "4.3.3.3") assert.Contains(t, createRecords[2], "300") } func TestRfc2136ApplyChangesWithUpdate(t *testing.T) { stub := newStub() provider, err := createRfc2136StubProvider(stub) assert.NoError(t, err) p := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, RecordTTL: endpoint.TTL(400), }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{"boom"}, }, }, } err = provider.ApplyChanges(t.Context(), p) assert.NoError(t, err) p = &plan.Changes{ UpdateOld: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{"1.2.3.4"}, RecordTTL: endpoint.TTL(400), }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{"boom"}, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "v1.foo.com", RecordType: "A", Targets: []string{"1.2.3.5"}, RecordTTL: endpoint.TTL(400), }, { DNSName: "v1.foobar.com", RecordType: "TXT", Targets: []string{"kablui"}, }, }, } err = provider.ApplyChanges(t.Context(), p) assert.NoError(t, err) assert.Len(t, stub.createMsgs, 4) assert.Len(t, stub.updateMsgs, 2) assert.Contains(t, stub.createMsgs[0].String(), "v1.foo.com") assert.Contains(t, stub.createMsgs[0].String(), "1.2.3.4") assert.Contains(t, stub.createMsgs[2].String(), "v1.foo.com") assert.Contains(t, stub.createMsgs[2].String(), "1.2.3.5") assert.Contains(t, stub.updateMsgs[0].String(), "v1.foo.com") assert.Contains(t, stub.updateMsgs[0].String(), "1.2.3.4") assert.Contains(t, stub.createMsgs[1].String(), "v1.foobar.com") assert.Contains(t, stub.createMsgs[1].String(), "boom") assert.Contains(t, stub.createMsgs[3].String(), "v1.foobar.com") assert.Contains(t, stub.createMsgs[3].String(), "kablui") assert.Contains(t, stub.updateMsgs[1].String(), "v1.foobar.com") assert.Contains(t, stub.updateMsgs[1].String(), "boom") } func TestChunkBy(t *testing.T) { var records []*endpoint.Endpoint for range 10 { records = append(records, &endpoint.Endpoint{ DNSName: "v1.foo.com", RecordType: "A", Targets: []string{"1.1.2.2"}, RecordTTL: endpoint.TTL(400), }) } chunks := chunkBy(records, 2) if len(chunks) != 5 { t.Errorf("incorrect number of chunks returned") } } func contains(arr []*endpoint.Endpoint, name string) bool { for _, a := range arr { if a.DNSName == name { return true } } return false } // TestCreateRfc2136StubProviderWithHosts validates the stub provider initializes with multiple nameservers. func TestCreateRfc2136StubProviderWithHosts(t *testing.T) { stub := newStub() provider, err := createRfc2136StubProviderWithHosts(stub) require.NoError(t, err) rawProvider, ok := provider.(*rfc2136Provider) assert.True(t, ok, "expected provider to be of type *rfc2136Provider") assert.Len(t, rawProvider.nameservers, 3) assert.Equal(t, "rfc2136-host1:0", rawProvider.nameservers[0]) assert.Equal(t, "rfc2136-host2:0", rawProvider.nameservers[1]) assert.Equal(t, "rfc2136-host3:0", rawProvider.nameservers[2]) } // TestRoundRobinLoadBalancing tests the round-robin load balancing strategy. func TestRoundRobinLoadBalancing(t *testing.T) { stub := newStubLB("round-robin", []string{"rfc2136-host1", "rfc2136-host2", "rfc2136-host3"}) _, err := createRfc2136StubProviderWithStrategy(stub, "round-robin") require.NoError(t, err) m := new(dns.Msg) m.SetUpdate("foo.com.") rr, err := dns.NewRR(fmt.Sprintf("%s %d %s %s", "v1.foo.com.", 0, "A", "1.2.3.4")) m.Insert([]dns.RR{rr}) for i := range 10 { err := stub.SendMessage(m) assert.NoError(t, err) expectedNameserver := "rfc2136-host" + strconv.Itoa((i%3)+1) assert.Equal(t, expectedNameserver, stub.lastNameserver) } } // TestRandomLoadBalancing tests the random load balancing strategy. func TestRandomLoadBalancing(t *testing.T) { stub := newStubLB("random", []string{"rfc2136-host1", "rfc2136-host2", "rfc2136-host3"}) _, err := createRfc2136StubProviderWithStrategy(stub, "random") require.NoError(t, err) m := new(dns.Msg) m.SetUpdate("foo.com.") rr, err := dns.NewRR(fmt.Sprintf("%s %d %s %s", "v1.foo.com.", 0, "A", "1.2.3.4")) m.Insert([]dns.RR{rr}) nameserverCounts := map[string]int{} for range 25 { err := stub.SendMessage(m) assert.NoError(t, err) nameserverCounts[stub.lastNameserver]++ } assert.Greater(t, len(nameserverCounts), 1, "Expected multiple nameservers to be used in random strategy") } // TestRfc2136ApplyChangesWithMultipleChunks tests Updates with multiple chunks func TestRfc2136ApplyChangesWithMultipleChunks(t *testing.T) { stub := newStub() provider, err := createRfc2136StubProviderWithBatchChangeSize(stub, 2) assert.NoError(t, err) var oldRecords []*endpoint.Endpoint var newRecords []*endpoint.Endpoint for i := 1; i <= 4; i++ { oldRecords = append(oldRecords, &endpoint.Endpoint{ DNSName: fmt.Sprintf("%s%d%s", "v", i, ".foo.com"), RecordType: "A", Targets: []string{fmt.Sprintf("10.0.0.%d", i)}, RecordTTL: endpoint.TTL(400), }) newRecords = append(newRecords, &endpoint.Endpoint{ DNSName: fmt.Sprintf("%s%d%s", "v", i, ".foo.com"), RecordType: "A", Targets: []string{fmt.Sprintf("10.0.1.%d", i)}, RecordTTL: endpoint.TTL(400), }) } p := &plan.Changes{ UpdateOld: oldRecords, UpdateNew: newRecords, } err = provider.ApplyChanges(t.Context(), p) assert.NoError(t, err) assert.Len(t, stub.updateMsgs, 4) assert.Contains(t, stub.updateMsgs[0].String(), "\nv1.foo.com.\t0\tNONE\tA\t10.0.0.1\nv1.foo.com.\t400\tIN\tA\t10.0.1.1\n") assert.Contains(t, stub.updateMsgs[0].String(), "\nv2.foo.com.\t0\tNONE\tA\t10.0.0.2\nv2.foo.com.\t400\tIN\tA\t10.0.1.2\n") assert.Contains(t, stub.updateMsgs[2].String(), "\nv3.foo.com.\t0\tNONE\tA\t10.0.0.3\nv3.foo.com.\t400\tIN\tA\t10.0.1.3\n") assert.Contains(t, stub.updateMsgs[2].String(), "\nv4.foo.com.\t0\tNONE\tA\t10.0.0.4\nv4.foo.com.\t400\tIN\tA\t10.0.1.4\n") } // Test stub that simulates nameserver connection failures type failingRfc2136Stub struct { rfc2136Stub } func (r *failingRfc2136Stub) SendMessage(_ *dns.Msg) error { return fmt.Errorf("failed to connect: dial tcp: lookup unreachable-nameserver: no such host") } func (r *failingRfc2136Stub) IncomeTransfer(_ *dns.Msg, _ string) (chan *dns.Envelope, error) { return nil, fmt.Errorf("failed to connect for transfer: dial tcp: lookup unreachable-nameserver: no such host") } // Test that nameserver failures return SoftError to prevent crashes func TestRfc2136NameserverFailureReturnsSoftError(t *testing.T) { // Create a stub that will fail all operations failingStub := &failingRfc2136Stub{ rfc2136Stub: rfc2136Stub{ output: make([]*dns.Envelope, 0), updateMsgs: make([]*dns.Msg, 0), createMsgs: make([]*dns.Msg, 0), nameservers: []string{"unreachable-nameserver:53"}, randGen: rand.New(rand.NewSource(time.Now().UnixNano())), loadBalancingStrategy: "round-robin", }, } tlsConfig := TLSConfig{ UseTLS: false, SkipTLSVerify: false, CAFilePath: "", ClientCertFilePath: "", ClientCertKeyFilePath: "", } providerInstance, err := newProvider( []string{"unreachable-nameserver"}, 53, []string{"example.com"}, false, "key", "secret", "hmac-sha512", true, &endpoint.DomainFilter{}, false, 300*time.Second, false, false, "", "", "", 50, tlsConfig, "round-robin", failingStub, ) assert.NoError(t, err) // Test that Records() returns a SoftError when nameserver fails _, err = providerInstance.Records(t.Context()) assert.Error(t, err) assert.ErrorIs(t, err, provider.SoftError, "Expected SoftError when nameserver fails") // Test that ApplyChanges() returns a SoftError when nameserver fails p := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "test.example.com", RecordType: "A", Targets: []string{"1.2.3.4"}, }, }, } err = providerInstance.ApplyChanges(t.Context(), p) assert.Error(t, err) assert.ErrorIs(t, err, provider.SoftError, "Expected SoftError when nameserver fails in ApplyChanges") } ================================================ FILE: provider/scaleway/interface.go ================================================ /* Copyright 2020 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 scaleway import ( domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" "github.com/scaleway/scaleway-sdk-go/scw" ) // DomainAPI is an interface matching the domain.API struct type DomainAPI interface { ListDNSZones(req *domain.ListDNSZonesRequest, opts ...scw.RequestOption) (*domain.ListDNSZonesResponse, error) ListDNSZoneRecords(req *domain.ListDNSZoneRecordsRequest, opts ...scw.RequestOption) (*domain.ListDNSZoneRecordsResponse, error) UpdateDNSZoneRecords(req *domain.UpdateDNSZoneRecordsRequest, opts ...scw.RequestOption) (*domain.UpdateDNSZoneRecordsResponse, error) } ================================================ FILE: provider/scaleway/scaleway.go ================================================ /* Copyright 2020 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 scaleway import ( "context" "fmt" "os" "strconv" "strings" domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" "github.com/scaleway/scaleway-sdk-go/scw" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( defaultTTL uint32 = 300 scalewayDefaultPriority uint32 = 0 scalewayPriorityKey string = "scw/priority" ) // ScalewayProvider implements the DNS provider for Scaleway DNS type ScalewayProvider struct { provider.BaseProvider domainAPI DomainAPI dryRun bool // only consider hosted zones managing domains ending in this suffix domainFilter *endpoint.DomainFilter } // ScalewayChange differentiates between ChangActions type ScalewayChange struct { Action string Record []domain.Record } // New creates a Scaleway provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(domainFilter, cfg.DryRun) } // newProvider initializes a new Scaleway DNS provider func newProvider(domainFilter *endpoint.DomainFilter, dryRun bool) (*ScalewayProvider, error) { var err error defaultPageSize := uint64(1000) if envPageSize, ok := os.LookupEnv("SCW_DEFAULT_PAGE_SIZE"); ok { defaultPageSize, err = strconv.ParseUint(envPageSize, 10, 32) if err != nil { log.Infof("Ignoring default page size %s, defaulting to 1000", envPageSize) defaultPageSize = 1000 } } p := &scw.Profile{} c, err := scw.LoadConfig() if err != nil { log.Warnf("Cannot load config: %v", err) } else { p, err = c.GetActiveProfile() if err != nil { log.Warnf("Cannot get active profile: %v", err) } } scwClient, err := scw.NewClient( scw.WithProfile(p), scw.WithEnv(), scw.WithUserAgent(externaldns.UserAgent()), scw.WithDefaultPageSize(uint32(defaultPageSize)), ) if err != nil { return nil, err } if _, ok := scwClient.GetAccessKey(); !ok { return nil, fmt.Errorf("access key no set") } if _, ok := scwClient.GetSecretKey(); !ok { return nil, fmt.Errorf("secret key no set") } domainAPI := domain.NewAPI(scwClient) return &ScalewayProvider{ domainAPI: domainAPI, dryRun: dryRun, domainFilter: domainFilter, }, nil } // AdjustEndpoints is used to normalize the endoints func (p *ScalewayProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { eps := make([]*endpoint.Endpoint, len(endpoints)) for i := range endpoints { eps[i] = endpoints[i] if !eps[i].RecordTTL.IsConfigured() { eps[i].RecordTTL = endpoint.TTL(defaultTTL) } if _, ok := eps[i].GetProviderSpecificProperty(scalewayPriorityKey); !ok { eps[i] = eps[i].WithProviderSpecific(scalewayPriorityKey, fmt.Sprintf("%d", scalewayDefaultPriority)) } } return eps, nil } // Zones returns the list of hosted zones. func (p *ScalewayProvider) Zones(ctx context.Context) ([]*domain.DNSZone, error) { res := []*domain.DNSZone{} dnsZones, err := p.domainAPI.ListDNSZones(&domain.ListDNSZonesRequest{}, scw.WithAllPages(), scw.WithContext(ctx)) if err != nil { return nil, err } for _, dnsZone := range dnsZones.DNSZones { if p.domainFilter.Match(getCompleteZoneName(dnsZone)) { res = append(res, dnsZone) } } return res, nil } // Records returns the list of records in a given zone. func (p *ScalewayProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { endpoints := map[string]*endpoint.Endpoint{} dnsZones, err := p.Zones(ctx) if err != nil { return nil, err } for _, zone := range dnsZones { recordsResp, err := p.domainAPI.ListDNSZoneRecords(&domain.ListDNSZoneRecordsRequest{ DNSZone: getCompleteZoneName(zone), }, scw.WithAllPages()) if err != nil { return nil, err } for _, record := range recordsResp.Records { name := record.Name + "." // trim any leading or ending dot fullRecordName := strings.Trim(name+getCompleteZoneName(zone), ".") if !provider.SupportedRecordType(record.Type.String()) { log.Infof("Skipping record %s because type %s is not supported", fullRecordName, record.Type.String()) continue } // in external DNS, same endpoint have the same ttl and same priority // it's not the case in Scaleway DNS. It should never happen, but if // the record is modified without going through ExternalDNS, we could have // different priorities of ttls for a same name. // In this case, we juste take the first one. if existingEndpoint, ok := endpoints[record.Type.String()+"/"+fullRecordName]; ok { existingEndpoint.Targets = append(existingEndpoint.Targets, record.Data) log.Infof("Appending target %s to record %s, using TTL and priority of target %s", record.Data, fullRecordName, existingEndpoint.Targets[0]) } else { ep := endpoint.NewEndpointWithTTL(fullRecordName, record.Type.String(), endpoint.TTL(record.TTL), record.Data) ep = ep.WithProviderSpecific(scalewayPriorityKey, fmt.Sprintf("%d", record.Priority)) endpoints[record.Type.String()+"/"+fullRecordName] = ep } } } returnedEndpoints := []*endpoint.Endpoint{} for _, ep := range endpoints { returnedEndpoints = append(returnedEndpoints, ep) } return returnedEndpoints, nil } // ApplyChanges applies a set of changes in a zone. func (p *ScalewayProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { requests, err := p.generateApplyRequests(ctx, changes) if err != nil { return err } for _, req := range requests { logChanges(req) if p.dryRun { log.Info("Running in dry run mode") continue } _, err := p.domainAPI.UpdateDNSZoneRecords(req, scw.WithContext(ctx)) if err != nil { return err } } return nil } func (p *ScalewayProvider) generateApplyRequests(ctx context.Context, changes *plan.Changes) ([]*domain.UpdateDNSZoneRecordsRequest, error) { returnedRequests := []*domain.UpdateDNSZoneRecordsRequest{} recordsToAdd := map[string]*domain.RecordChangeAdd{} recordsToDelete := map[string][]*domain.RecordChange{} dnsZones, err := p.Zones(ctx) if err != nil { return nil, err } zoneNameMapper := provider.ZoneIDName{} for _, zone := range dnsZones { zoneName := getCompleteZoneName(zone) zoneNameMapper.Add(zoneName, zoneName) recordsToAdd[zoneName] = &domain.RecordChangeAdd{ Records: []*domain.Record{}, } recordsToDelete[zoneName] = []*domain.RecordChange{} } log.Debugf("Following records present in updateOld") for _, c := range changes.UpdateOld { zone, _ := zoneNameMapper.FindZone(c.DNSName) if zone == "" { log.Infof("Ignore record %s since it's not handled by ExternalDNS", c.DNSName) continue } recordsToDelete[zone] = append(recordsToDelete[zone], endpointToScalewayRecordsChangeDelete(zone, c)...) log.Debugf("%s", c.String()) } log.Debugf("Following records present in delete") for _, c := range changes.Delete { zone, _ := zoneNameMapper.FindZone(c.DNSName) if zone == "" { log.Infof("Ignore record %s since it's not handled by ExternalDNS", c.DNSName) continue } recordsToDelete[zone] = append(recordsToDelete[zone], endpointToScalewayRecordsChangeDelete(zone, c)...) log.Debugf("%s", c.String()) } log.Debugf("Following records present in create") for _, c := range changes.Create { zone, _ := zoneNameMapper.FindZone(c.DNSName) if zone == "" { log.Infof("Ignore record %s since it's not handled by ExternalDNS", c.DNSName) continue } recordsToAdd[zone].Records = append(recordsToAdd[zone].Records, endpointToScalewayRecords(zone, c)...) log.Debugf("%s", c.String()) } log.Debugf("Following records present in updateNew") for _, c := range changes.UpdateNew { zone, _ := zoneNameMapper.FindZone(c.DNSName) if zone == "" { log.Infof("Ignore record %s since it's not handled by ExternalDNS", c.DNSName) continue } recordsToAdd[zone].Records = append(recordsToAdd[zone].Records, endpointToScalewayRecords(zone, c)...) log.Debugf("%s", c.String()) } for _, zone := range dnsZones { zoneName := getCompleteZoneName(zone) req := &domain.UpdateDNSZoneRecordsRequest{ DNSZone: zoneName, Changes: recordsToDelete[zoneName], } req.Changes = append(req.Changes, &domain.RecordChange{ Add: recordsToAdd[zoneName], }) // ignore sending empty update requests if len(req.Changes) == 1 && len(req.Changes[0].Add.Records) == 0 { continue } returnedRequests = append(returnedRequests, req) } return returnedRequests, nil } func getCompleteZoneName(zone *domain.DNSZone) string { subdomain := zone.Subdomain + "." if zone.Subdomain == "" { subdomain = "" } return subdomain + zone.Domain } func endpointToScalewayRecords(zoneName string, ep *endpoint.Endpoint) []*domain.Record { // no annotation results in a TTL of 0, default to 300 for consistency with other providers ttl := defaultTTL if ep.RecordTTL.IsConfigured() { ttl = uint32(ep.RecordTTL) } priority := scalewayDefaultPriority if prop, ok := ep.GetProviderSpecificProperty(scalewayPriorityKey); ok { prio, err := strconv.ParseUint(prop, 10, 32) if err != nil { log.Errorf("Failed parsing value of %s: %s: %v; using priority of %d", scalewayPriorityKey, prop, err, scalewayDefaultPriority) } else { priority = uint32(prio) } } records := []*domain.Record{} for _, target := range ep.Targets { finalTargetName := target if domain.RecordType(ep.RecordType) == domain.RecordTypeCNAME { finalTargetName = provider.EnsureTrailingDot(target) } records = append(records, &domain.Record{ Data: finalTargetName, Name: strings.Trim(strings.TrimSuffix(ep.DNSName, zoneName), ". "), Priority: priority, TTL: ttl, Type: domain.RecordType(ep.RecordType), }) } return records } func endpointToScalewayRecordsChangeDelete(zoneName string, ep *endpoint.Endpoint) []*domain.RecordChange { records := []*domain.RecordChange{} for _, target := range ep.Targets { finalTargetName := target if domain.RecordType(ep.RecordType) == domain.RecordTypeCNAME { finalTargetName = provider.EnsureTrailingDot(target) } records = append(records, &domain.RecordChange{ Delete: &domain.RecordChangeDelete{ IDFields: &domain.RecordIdentifier{ Data: &finalTargetName, Name: strings.Trim(strings.TrimSuffix(ep.DNSName, zoneName), ". "), Type: domain.RecordType(ep.RecordType), }, }, }) } return records } func logChanges(req *domain.UpdateDNSZoneRecordsRequest) { if !log.IsLevelEnabled(log.InfoLevel) { return } log.Infof("Updating zone %s", req.DNSZone) for _, change := range req.Changes { if change.Add != nil { for _, add := range change.Add.Records { name := add.Name + "." if add.Name == "" { name = "" } logFields := log.Fields{ "record": name + req.DNSZone, "type": add.Type.String(), "ttl": add.TTL, "priority": add.Priority, "data": add.Data, } log.WithFields(logFields).Info("Adding record") } } else if change.Delete != nil { name := change.Delete.IDFields.Name + "." if change.Delete.IDFields.Name == "" { name = "" } logFields := log.Fields{ "record": name + req.DNSZone, "type": change.Delete.IDFields.Type.String(), "data": *change.Delete.IDFields.Data, } log.WithFields(logFields).Info("Deleting record") } } } ================================================ FILE: provider/scaleway/scaleway_test.go ================================================ /* Copyright 2020 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 scaleway import ( "io" "os" "reflect" "testing" domain "github.com/scaleway/scaleway-sdk-go/api/domain/v2beta1" "github.com/scaleway/scaleway-sdk-go/scw" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) type mockScalewayDomain struct { *domain.API } func (m *mockScalewayDomain) ListDNSZones(_ *domain.ListDNSZonesRequest, _ ...scw.RequestOption) (*domain.ListDNSZonesResponse, error) { return &domain.ListDNSZonesResponse{ DNSZones: []*domain.DNSZone{ { Domain: "example.com", Subdomain: "", }, { Domain: "example.com", Subdomain: "test", }, { Domain: "dummy.me", Subdomain: "", }, { Domain: "dummy.me", Subdomain: "test", }, }, }, nil } func (m *mockScalewayDomain) ListDNSZoneRecords(req *domain.ListDNSZoneRecordsRequest, _ ...scw.RequestOption) (*domain.ListDNSZoneRecordsResponse, error) { records := []*domain.Record{} if req.DNSZone == "example.com" { records = []*domain.Record{ { Data: "1.1.1.1", Name: "one", TTL: 300, Priority: 0, Type: domain.RecordTypeA, }, { Data: "1.1.1.2", Name: "two", TTL: 300, Priority: 0, Type: domain.RecordTypeA, }, { Data: "1.1.1.3", Name: "two", TTL: 300, Priority: 0, Type: domain.RecordTypeA, }, } } else if req.DNSZone == "test.example.com" { records = []*domain.Record{ { Data: "1.1.1.1", Name: "", TTL: 300, Priority: 0, Type: domain.RecordTypeA, }, { Data: "test.example.com.", Name: "two", TTL: 600, Priority: 30, Type: domain.RecordTypeCNAME, }, } } return &domain.ListDNSZoneRecordsResponse{ Records: records, }, nil } func (m *mockScalewayDomain) UpdateDNSZoneRecords(_ *domain.UpdateDNSZoneRecordsRequest, _ ...scw.RequestOption) (*domain.UpdateDNSZoneRecordsResponse, error) { return &domain.UpdateDNSZoneRecordsResponse{}, nil } func TestScalewayProvider_NewScalewayProvider(t *testing.T) { profile := `profiles: foo: access_key: SCWXXXXXXXXXXXXXXXXX secret_key: 11111111-1111-1111-1111-111111111111 ` tmpDir := t.TempDir() err := os.WriteFile(tmpDir+"/config.yaml", []byte(profile), 0600) if err != nil { t.Errorf("failed : %s", err) } t.Setenv(scw.ScwActiveProfileEnv, "foo") t.Setenv(scw.ScwConfigPathEnv, tmpDir+"/config.yaml") _, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err != nil { t.Errorf("failed : %s", err) } t.Setenv(scw.ScwAccessKeyEnv, "SCWXXXXXXXXXXXXXXXXX") t.Setenv(scw.ScwSecretKeyEnv, "11111111-1111-1111-1111-111111111111") _, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err != nil { t.Errorf("failed : %s", err) } _ = os.Unsetenv(scw.ScwSecretKeyEnv) _, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err == nil { t.Errorf("expected to fail") } t.Setenv(scw.ScwSecretKeyEnv, "dummy") _, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err == nil { t.Errorf("expected to fail") } _ = os.Unsetenv(scw.ScwAccessKeyEnv) t.Setenv(scw.ScwSecretKeyEnv, "11111111-1111-1111-1111-111111111111") _, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err == nil { t.Errorf("expected to fail") } t.Setenv(scw.ScwAccessKeyEnv, "dummy") _, err = newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) if err == nil { t.Errorf("expected to fail") } } func TestScalewayProvider_OptionnalConfigFile(t *testing.T) { log.SetOutput(io.Discard) t.Setenv(scw.ScwAccessKeyEnv, "SCWXXXXXXXXXXXXXXXXX") t.Setenv(scw.ScwSecretKeyEnv, "11111111-1111-1111-1111-111111111111") _, err := newProvider(endpoint.NewDomainFilter([]string{"example.com"}), true) assert.NoError(t, err) } func TestScalewayProvider_AdjustEndpoints(t *testing.T) { provider := &ScalewayProvider{} before := []*endpoint.Endpoint{ { DNSName: "one.example.com", RecordTTL: 300, RecordType: "A", Targets: []string{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "0", }, }, }, { DNSName: "two.example.com", RecordTTL: 0, RecordType: "A", Targets: []string{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "10", }, }, }, { DNSName: "three.example.com", RecordTTL: 600, RecordType: "A", Targets: []string{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{}, }, } expected := []*endpoint.Endpoint{ { DNSName: "one.example.com", RecordTTL: 300, RecordType: "A", Targets: []string{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "0", }, }, }, { DNSName: "two.example.com", RecordTTL: 300, RecordType: "A", Targets: []string{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "10", }, }, }, { DNSName: "three.example.com", RecordTTL: 600, RecordType: "A", Targets: []string{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "0", }, }, }, } after, err := provider.AdjustEndpoints(before) require.NoError(t, err) for i := range after { if !checkRecordEquality(after[i], expected[i]) { t.Errorf("got record %s instead of %s", after[i], expected[i]) } } } func TestScalewayProvider_Zones(t *testing.T) { mocked := mockScalewayDomain{nil} provider := &ScalewayProvider{ domainAPI: &mocked, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } expected := []*domain.DNSZone{ { Domain: "example.com", Subdomain: "", }, { Domain: "example.com", Subdomain: "test", }, } zones, err := provider.Zones(t.Context()) if err != nil { t.Fatal(err) } require.Len(t, zones, len(expected)) for i, zone := range zones { assert.Equal(t, expected[i], zone) } } func TestScalewayProvider_Records(t *testing.T) { mocked := mockScalewayDomain{nil} provider := &ScalewayProvider{ domainAPI: &mocked, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } expected := []*endpoint.Endpoint{ { DNSName: "one.example.com", RecordTTL: 300, RecordType: "A", Targets: []string{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "0", }, }, }, { DNSName: "two.example.com", RecordTTL: 300, RecordType: "A", Targets: []string{"1.1.1.2", "1.1.1.3"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "0", }, }, }, { DNSName: "test.example.com", RecordTTL: 300, RecordType: "A", Targets: []string{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "0", }, }, }, { DNSName: "two.test.example.com", RecordTTL: 600, RecordType: "CNAME", Targets: []string{"test.example.com"}, ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "30", }, }, }, } records, err := provider.Records(t.Context()) if err != nil { t.Fatal(err) } require.Len(t, records, len(expected)) for _, record := range records { found := false for _, expectedRecord := range expected { if checkRecordEquality(record, expectedRecord) { found = true } } assert.True(t, found) } } // this test is really ugly since we are working on maps, so array are randomly sorted // feel free to modify if you have a better idea func TestScalewayProvider_generateApplyRequests(t *testing.T) { mocked := mockScalewayDomain{nil} provider := &ScalewayProvider{ domainAPI: &mocked, domainFilter: endpoint.NewDomainFilter([]string{"example.com"}), } expected := []*domain.UpdateDNSZoneRecordsRequest{ { DNSZone: "example.com", Changes: []*domain.RecordChange{ { Add: &domain.RecordChangeAdd{ Records: []*domain.Record{ { Data: "1.1.1.1", Name: "", TTL: 300, Type: domain.RecordTypeA, Priority: 0, }, { Data: "1.1.1.2", Name: "", TTL: 300, Type: domain.RecordTypeA, Priority: 0, }, { Data: "2.2.2.2", Name: "me", TTL: 600, Type: domain.RecordTypeA, Priority: 30, }, }, }, }, { Delete: &domain.RecordChangeDelete{ IDFields: &domain.RecordIdentifier{ Data: scw.StringPtr("3.3.3.3"), Name: "me", Type: domain.RecordTypeA, }, }, }, { Delete: &domain.RecordChangeDelete{ IDFields: &domain.RecordIdentifier{ Data: scw.StringPtr("1.1.1.1"), Name: "here", Type: domain.RecordTypeA, }, }, }, { Delete: &domain.RecordChangeDelete{ IDFields: &domain.RecordIdentifier{ Data: scw.StringPtr("1.1.1.2"), Name: "here", Type: domain.RecordTypeA, }, }, }, }, }, { DNSZone: "test.example.com", Changes: []*domain.RecordChange{ { Add: &domain.RecordChangeAdd{ Records: []*domain.Record{ { Data: "example.com.", Name: "", TTL: 600, Type: domain.RecordTypeCNAME, Priority: 20, }, { Data: "1.2.3.4", Name: "my", TTL: 300, Type: domain.RecordTypeA, Priority: 0, }, { Data: "5.6.7.8", Name: "my", TTL: 300, Type: domain.RecordTypeA, Priority: 0, }, }, }, }, { Delete: &domain.RecordChangeDelete{ IDFields: &domain.RecordIdentifier{ Data: scw.StringPtr("1.1.1.1"), Name: "here.is.my", Type: domain.RecordTypeA, }, }, }, { Delete: &domain.RecordChangeDelete{ IDFields: &domain.RecordIdentifier{ Data: scw.StringPtr("4.4.4.4"), Name: "my", Type: domain.RecordTypeA, }, }, }, { Delete: &domain.RecordChangeDelete{ IDFields: &domain.RecordIdentifier{ Data: scw.StringPtr("5.5.5.5"), Name: "my", Type: domain.RecordTypeA, }, }, }, }, }, } changes := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "example.com", RecordType: "A", Targets: []string{"1.1.1.1", "1.1.1.2"}, }, { DNSName: "test.example.com", RecordType: "CNAME", ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "20", }, }, RecordTTL: 600, Targets: []string{"example.com"}, }, }, Delete: []*endpoint.Endpoint{ { DNSName: "here.example.com", RecordType: "A", Targets: []string{"1.1.1.1", "1.1.1.2"}, }, { DNSName: "here.is.my.test.example.com", RecordType: "A", Targets: []string{"1.1.1.1"}, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "me.example.com", ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "30", }, }, RecordType: "A", RecordTTL: 600, Targets: []string{"2.2.2.2"}, }, { DNSName: "my.test.example.com", RecordType: "A", Targets: []string{"1.2.3.4", "5.6.7.8"}, }, }, UpdateOld: []*endpoint.Endpoint{ { DNSName: "me.example.com", ProviderSpecific: endpoint.ProviderSpecific{ { Name: scalewayPriorityKey, Value: "1234", }, }, RecordType: "A", Targets: []string{"3.3.3.3"}, }, { DNSName: "my.test.example.com", RecordType: "A", Targets: []string{"4.4.4.4", "5.5.5.5"}, }, }, } requests, err := provider.generateApplyRequests(t.Context(), changes) if err != nil { t.Fatal(err) } require.Len(t, requests, len(expected)) total := int(len(expected)) for _, req := range requests { for _, exp := range expected { if checkScalewayReqChanges(req, exp) { total-- } } } assert.Equal(t, 0, total) } func checkRecordEquality(record1, record2 *endpoint.Endpoint) bool { return record1.Targets.Same(record2.Targets) && record1.DNSName == record2.DNSName && record1.RecordTTL == record2.RecordTTL && record1.RecordType == record2.RecordType && reflect.DeepEqual(record1.ProviderSpecific, record2.ProviderSpecific) } func checkScalewayReqChanges(r1, r2 *domain.UpdateDNSZoneRecordsRequest) bool { if r1.DNSZone != r2.DNSZone { return false } if len(r1.Changes) != len(r2.Changes) { return false } total := int(len(r1.Changes)) for _, c1 := range r1.Changes { for _, c2 := range r2.Changes { // we only have 1 add per request if c1.Add != nil && c2.Add != nil && checkScalewayRecords(c1.Add.Records, c2.Add.Records) { total-- } else if c1.Delete != nil && c2.Delete != nil { if *c1.Delete.IDFields.Data == *c2.Delete.IDFields.Data && c1.Delete.IDFields.Name == c2.Delete.IDFields.Name && c1.Delete.IDFields.Type == c2.Delete.IDFields.Type { total-- } } } } return total == 0 } func checkScalewayRecords(rs1, rs2 []*domain.Record) bool { if len(rs1) != len(rs2) { return false } total := int(len(rs1)) for _, r1 := range rs1 { for _, r2 := range rs2 { if r1.Data == r2.Data && r1.Name == r2.Name && r1.Priority == r2.Priority && r1.TTL == r2.TTL && r1.Type == r2.Type { total-- } } } return total == 0 } ================================================ FILE: provider/transip/transip.go ================================================ /* Copyright 2017 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 transip import ( "context" "errors" "fmt" "strings" log "github.com/sirupsen/logrus" "github.com/transip/gotransip/v6" "github.com/transip/gotransip/v6/domain" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( // 60 seconds is the current minimal TTL for TransIP and will replace unconfigured // TTL's for Endpoints defaultTTL = 60 ) // TransIPProvider is an implementation of Provider for TransIP. type TransIPProvider struct { provider.BaseProvider domainRepo domain.Repository domainFilter *endpoint.DomainFilter dryRun bool zoneMap provider.ZoneIDName } // New creates a TransIP provider from the given configuration. func New(_ context.Context, cfg *externaldns.Config, domainFilter *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(cfg.TransIPAccountName, cfg.TransIPPrivateKeyFile, domainFilter, cfg.DryRun) } // newProvider initializes a new TransIP Provider. func newProvider(accountName, privateKeyFile string, domainFilter *endpoint.DomainFilter, dryRun bool) (*TransIPProvider, error) { // check given arguments if accountName == "" { return nil, errors.New("required --transip-account not set") } if privateKeyFile == "" { return nil, errors.New("required --transip-keyfile not set") } var apiMode gotransip.APIMode if dryRun { apiMode = gotransip.APIModeReadOnly } else { apiMode = gotransip.APIModeReadWrite } // create new TransIP API client client, err := gotransip.NewClient(gotransip.ClientConfiguration{ AccountName: accountName, PrivateKeyPath: privateKeyFile, Mode: apiMode, }) if err != nil { return nil, fmt.Errorf("could not setup TransIP API client: %w", err) } // return TransIPProvider struct return &TransIPProvider{ domainRepo: domain.Repository{Client: client}, domainFilter: domainFilter, dryRun: dryRun, zoneMap: provider.ZoneIDName{}, }, nil } // ApplyChanges applies a given set of changes in a given zone. func (p *TransIPProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { // fetch all zones we currently have // this does NOT include any DNS entries, so we'll have to fetch these for // each zone that gets updated zones, err := p.domainRepo.GetAll() if err != nil { return err } // refresh zone mapping zoneMap := provider.ZoneIDName{} for _, zone := range zones { // TransIP API doesn't expose a unique identifier for zones, other than // the domain name itself zoneMap.Add(zone.Name, zone.Name) } p.zoneMap = zoneMap // first remove obsolete DNS records for _, ep := range changes.Delete { epLog := log.WithFields(log.Fields{ "record": ep.DNSName, "type": ep.RecordType, }) epLog.Info("endpoint has to go") zoneName, entries, err := p.entriesForEndpoint(ep) if err != nil { epLog.WithError(err).Error("could not get DNS entries") return err } epLog = epLog.WithField("zone", zoneName) if len(entries) == 0 { epLog.Info("no matching entries found") continue } if p.dryRun { epLog.Info("not removing DNS entries in dry-run mode") continue } for _, entry := range entries { log.WithFields(log.Fields{ "domain": zoneName, "name": entry.Name, "type": entry.Type, "content": entry.Content, "ttl": entry.Expire, }).Info("removing DNS entry") err = p.domainRepo.RemoveDNSEntry(zoneName, entry) if err != nil { epLog.WithError(err).Error("could not remove DNS entry") return err } } } // then create new DNS records for _, ep := range changes.Create { epLog := log.WithFields(log.Fields{ "record": ep.DNSName, "type": ep.RecordType, }) epLog.Info("endpoint should be created") zoneName, err := p.zoneNameForDNSName(ep.DNSName) if err != nil { epLog.WithError(err).Warn("could not find zone for endpoint") continue } epLog = epLog.WithField("zone", zoneName) if p.dryRun { epLog.Info("not adding DNS entries in dry-run mode") continue } for _, entry := range dnsEntriesForEndpoint(ep, zoneName) { log.WithFields(log.Fields{ "domain": zoneName, "name": entry.Name, "type": entry.Type, "content": entry.Content, "ttl": entry.Expire, }).Info("creating DNS entry") err = p.domainRepo.AddDNSEntry(zoneName, entry) if err != nil { epLog.WithError(err).Error("could not add DNS entry") return err } } } // then update existing DNS records for _, ep := range changes.UpdateNew { epLog := log.WithFields(log.Fields{ "record": ep.DNSName, "type": ep.RecordType, }) epLog.Debug("endpoint needs updating") zoneName, entries, err := p.entriesForEndpoint(ep) if err != nil { epLog.WithError(err).Error("could not get DNS entries") return err } epLog = epLog.WithField("zone", zoneName) if len(entries) == 0 { epLog.Info("no matching entries found") continue } newEntries := dnsEntriesForEndpoint(ep, zoneName) // check to see if actually anything changed in the DNSEntry set if dnsEntriesAreEqual(newEntries, entries) { epLog.Debug("not updating identical DNS entries") continue } if p.dryRun { epLog.Info("not updating DNS entries in dry-run mode") continue } // TransIP API client does have an UpdateDNSEntry call but that does only // allow you to update the content of a DNSEntry, not the TTL // to work around this, remove the old entry first and add the new entry for _, entry := range entries { log.WithFields(log.Fields{ "domain": zoneName, "name": entry.Name, "type": entry.Type, "content": entry.Content, "ttl": entry.Expire, }).Info("removing DNS entry") err = p.domainRepo.RemoveDNSEntry(zoneName, entry) if err != nil { epLog.WithError(err).Error("could not remove DNS entry") return err } } for _, entry := range newEntries { log.WithFields(log.Fields{ "domain": zoneName, "name": entry.Name, "type": entry.Type, "content": entry.Content, "ttl": entry.Expire, }).Info("adding DNS entry") err = p.domainRepo.AddDNSEntry(zoneName, entry) if err != nil { epLog.WithError(err).Error("could not add DNS entry") return err } } } return nil } // Records returns the list of records in all zones func (p *TransIPProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.domainRepo.GetAll() if err != nil { return nil, err } var endpoints []*endpoint.Endpoint // go over all zones and their DNS entries and create endpoints for them for _, zone := range zones { entries, err := p.domainRepo.GetDNSEntries(zone.Name) if err != nil { return nil, err } for _, r := range entries { if !provider.SupportedRecordType(r.Type) { continue } name := endpointNameForRecord(r, zone.Name) endpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, r.Type, endpoint.TTL(r.Expire), r.Content)) } } return endpoints, nil } func (p *TransIPProvider) entriesForEndpoint(ep *endpoint.Endpoint) (string, []domain.DNSEntry, error) { zoneName, err := p.zoneNameForDNSName(ep.DNSName) if err != nil { return "", nil, err } epName := recordNameForEndpoint(ep, zoneName) dnsEntries, err := p.domainRepo.GetDNSEntries(zoneName) if err != nil { return zoneName, nil, err } matches := []domain.DNSEntry{} for _, entry := range dnsEntries { if ep.RecordType != entry.Type { continue } if entry.Name == epName { matches = append(matches, entry) } } return zoneName, matches, nil } // endpointNameForRecord returns "www.example.org" for DNSEntry with Name "www" and // Domain with Name "example.org" func endpointNameForRecord(r domain.DNSEntry, zoneName string) string { // root name is identified by "@" and should be translated to domain name for // the endpoint entry. if r.Name == "@" { return zoneName } return fmt.Sprintf("%s.%s", r.Name, zoneName) } // recordNameForEndpoint returns "www" for Endpoint with DNSName "www.example.org" // and Domain with Name "example.org" func recordNameForEndpoint(ep *endpoint.Endpoint, zoneName string) string { // root name is identified by "@" and should be translated to domain name for // the endpoint entry. if ep.DNSName == zoneName { return "@" } return strings.TrimSuffix(ep.DNSName, "."+zoneName) } // getMinimalValidTTL returns max between given Endpoint's RecordTTL and // defaultTTL func getMinimalValidTTL(ep *endpoint.Endpoint) int { // TTL cannot be lower than defaultTTL if ep.RecordTTL < defaultTTL { return defaultTTL } return int(ep.RecordTTL) } // dnsEntriesAreEqual compares the entries in 2 sets and returns true if the // content of the entries is equal func dnsEntriesAreEqual(a, b []domain.DNSEntry) bool { if len(a) != len(b) { return false } match := 0 for _, aa := range a { for _, bb := range b { if aa.Content != bb.Content { continue } if aa.Name != bb.Name { continue } if aa.Expire != bb.Expire { continue } if aa.Type != bb.Type { continue } match++ } } return (len(a) == match) } // dnsEntriesForEndpoint creates DNS entries for given endpoint and returns // resulting DNS entry set func dnsEntriesForEndpoint(ep *endpoint.Endpoint, zoneName string) []domain.DNSEntry { ttl := getMinimalValidTTL(ep) entries := []domain.DNSEntry{} for _, target := range ep.Targets { // external hostnames require a trailing dot in TransIP API if ep.RecordType == "CNAME" { target = provider.EnsureTrailingDot(target) } entries = append(entries, domain.DNSEntry{ Name: recordNameForEndpoint(ep, zoneName), Expire: ttl, Type: ep.RecordType, Content: target, }) } return entries } // zoneForZoneName returns the zone mapped to given name or error if zone could // not be found func (p *TransIPProvider) zoneNameForDNSName(name string) (string, error) { _, zoneName := p.zoneMap.FindZone(name) if zoneName == "" { return "", fmt.Errorf("could not find zoneName for %s", name) } return zoneName, nil } ================================================ FILE: provider/transip/transip_test.go ================================================ /* Copyright 2017 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 transip import ( "encoding/json" "errors" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/transip/gotransip/v6/domain" "github.com/transip/gotransip/v6/rest" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/provider" ) func newTestProvider() *TransIPProvider { return &TransIPProvider{ zoneMap: provider.ZoneIDName{}, } } func TestTransIPDnsEntriesAreEqual(t *testing.T) { // test with equal set a := []domain.DNSEntry{ { Name: "www.example.org", Type: "CNAME", Expire: 3600, Content: "www.example.com", }, { Name: "www.example.com", Type: "A", Expire: 3600, Content: "192.168.0.1", }, } b := []domain.DNSEntry{ { Name: "www.example.com", Type: "A", Expire: 3600, Content: "192.168.0.1", }, { Name: "www.example.org", Type: "CNAME", Expire: 3600, Content: "www.example.com", }, } assert.True(t, dnsEntriesAreEqual(a, b)) // change type on one of b's records b[1].Type = "NS" assert.False(t, dnsEntriesAreEqual(a, b)) b[1].Type = "CNAME" // change ttl on one of b's records b[1].Expire = 1800 assert.False(t, dnsEntriesAreEqual(a, b)) b[1].Expire = 3600 // change name on one of b's records b[1].Name = "example.org" assert.False(t, dnsEntriesAreEqual(a, b)) // remove last entry of b b = b[:1] assert.False(t, dnsEntriesAreEqual(a, b)) } func TestTransIPGetMinimalValidTTL(t *testing.T) { // test with 'unconfigured' TTL ep := &endpoint.Endpoint{} assert.Equal(t, defaultTTL, getMinimalValidTTL(ep)) // test with lower than minimal ttl ep.RecordTTL = (defaultTTL - 1) assert.Equal(t, defaultTTL, getMinimalValidTTL(ep)) // test with higher than minimal ttl ep.RecordTTL = (defaultTTL + 1) assert.Equal(t, defaultTTL+1, getMinimalValidTTL(ep)) } func TestTransIPRecordNameForEndpoint(t *testing.T) { ep := &endpoint.Endpoint{ DNSName: "example.org", } d := domain.Domain{ Name: "example.org", } assert.Equal(t, "@", recordNameForEndpoint(ep, d.Name)) ep.DNSName = "www.example.org" assert.Equal(t, "www", recordNameForEndpoint(ep, d.Name)) } func TestTransIPEndpointNameForRecord(t *testing.T) { r := domain.DNSEntry{ Name: "@", } d := domain.Domain{ Name: "example.org", } assert.Equal(t, d.Name, endpointNameForRecord(r, d.Name)) r.Name = "www" assert.Equal(t, "www.example.org", endpointNameForRecord(r, d.Name)) } func TestTransIPAddEndpointToEntries(t *testing.T) { // prepare endpoint ep := &endpoint.Endpoint{ DNSName: "www.example.org", RecordType: "A", RecordTTL: 1800, Targets: []string{ "192.168.0.1", "192.168.0.2", }, } // prepare zone with DNS entry set zone := domain.Domain{ Name: "example.org", } // add endpoint to zone's entries result := dnsEntriesForEndpoint(ep, zone.Name) if assert.Len(t, result, 2) { assert.Equal(t, "www", result[0].Name) assert.Equal(t, "A", result[0].Type) assert.Equal(t, "192.168.0.1", result[0].Content) assert.Equal(t, 1800, result[0].Expire) assert.Equal(t, "www", result[1].Name) assert.Equal(t, "A", result[1].Type) assert.Equal(t, "192.168.0.2", result[1].Content) assert.Equal(t, 1800, result[1].Expire) } // try again with CNAME ep.RecordType = "CNAME" ep.Targets = []string{"foo.bar"} result = dnsEntriesForEndpoint(ep, zone.Name) if assert.Len(t, result, 1) { assert.Equal(t, "CNAME", result[0].Type) assert.Equal(t, "foo.bar.", result[0].Content) } } func TestZoneNameForDNSName(t *testing.T) { p := newTestProvider() p.zoneMap.Add("example.com", "example.com") zoneName, err := p.zoneNameForDNSName("www.example.com") if assert.NoError(t, err) { assert.Equal(t, "example.com", zoneName) } _, err = p.zoneNameForDNSName("www.example.org") if assert.Error(t, err) { assert.Equal(t, "could not find zoneName for www.example.org", err.Error()) } } // fakeClient mocks the REST API client type fakeClient struct { getFunc func(rest.Request, any) error } func (f *fakeClient) Get(request rest.Request, dest any) error { if f.getFunc == nil { return errors.New("GET not defined") } return f.getFunc(request, dest) } func (f *fakeClient) Put(_ rest.Request) error { return errors.New("PUT not implemented") } func (f *fakeClient) Post(_ rest.Request) error { return errors.New("POST not implemented") } func (f *fakeClient) Delete(_ rest.Request) error { return errors.New("DELETE not implemented") } func (f *fakeClient) Patch(_ rest.Request) error { return errors.New("PATCH not implemented") } func (f *fakeClient) PatchWithResponse(_ rest.Request) (rest.Response, error) { return rest.Response{}, errors.New("PATCH with response not implemented") } func (f *fakeClient) PostWithResponse(_ rest.Request) (rest.Response, error) { return rest.Response{}, errors.New("POST with response not implemented") } func (f *fakeClient) PutWithResponse(_ rest.Request) (rest.Response, error) { return rest.Response{}, errors.New("PUT with response not implemented") } func TestProviderRecords(t *testing.T) { // set up the fake REST client client := &fakeClient{} client.getFunc = func(req rest.Request, dest any) error { var data []byte switch { case req.Endpoint == "/domains": // return list of some domain names // only, other fields are not used data = []byte(`{"domains":[{"name":"example.org"}, {"name":"example.com"}]}`) case strings.HasSuffix(req.Endpoint, "/dns"): // return list of DNS entries // also some unsupported types data = []byte(`{"dnsEntries":[{"name":"www", "expire":1234, "type":"CNAME", "content":"@"},{"type":"MX"},{"type":"AAAA"}]}`) } // unmarshal the prepared return data into the given destination type return json.Unmarshal(data, &dest) } // set up provider p := newTestProvider() p.domainRepo = domain.Repository{Client: client} endpoints, err := p.Records(t.Context()) if assert.NoError(t, err) { if assert.Len(t, endpoints, 4) { assert.Equal(t, "www.example.org", endpoints[0].DNSName) assert.Equal(t, "@", endpoints[0].Targets[0]) assert.Equal(t, "CNAME", endpoints[0].RecordType) assert.Empty(t, endpoints[0].Labels) assert.EqualValues(t, 1234, endpoints[0].RecordTTL) } } } func TestProviderEntriesForEndpoint(t *testing.T) { // set up fake REST client client := &fakeClient{} // set up provider p := newTestProvider() p.domainRepo = domain.Repository{Client: client} p.zoneMap.Add("example.com", "example.com") // get entries for endpoint with unknown zone _, _, err := p.entriesForEndpoint(&endpoint.Endpoint{ DNSName: "www.example.org", }) if assert.Error(t, err) { assert.Equal(t, "could not find zoneName for www.example.org", err.Error()) } // get entries for endpoint with known zone but client returns error // we leave GET functions undefined so we know which error to expect zoneName, _, err := p.entriesForEndpoint(&endpoint.Endpoint{ DNSName: "www.example.com", }) if assert.Error(t, err) { assert.Equal(t, "GET not defined", err.Error()) } assert.Equal(t, "example.com", zoneName) // to be able to return a valid set of DNS entries through the API, we define // some first, then JSON encode them and have the fake API client's Get function // return that // in this set are some entries that do and others that don't match the given // endpoint dnsEntries := []domain.DNSEntry{ { Name: "www", Type: "A", Expire: 3600, Content: "1.2.3.4", }, { Name: "ftp", Type: "A", Expire: 86400, Content: "3.4.5.6", }, { Name: "www", Type: "A", Expire: 3600, Content: "2.3.4.5", }, { Name: "www", Type: "CNAME", Expire: 3600, Content: "@", }, } var v struct { DNSEntries []domain.DNSEntry `json:"dnsEntries"` } v.DNSEntries = dnsEntries returnData, err := json.Marshal(&v) require.NoError(t, err) // define GET function client.getFunc = func(_ rest.Request, dest any) error { // unmarshal the prepared return data into the given dnsEntriesWrapper return json.Unmarshal(returnData, &dest) } _, entries, err := p.entriesForEndpoint(&endpoint.Endpoint{ DNSName: "www.example.com", RecordType: "A", }) if assert.NoError(t, err) { if assert.Len(t, entries, 2) { // only first and third entry should be returned assert.Equal(t, dnsEntries[0], entries[0]) assert.Equal(t, dnsEntries[2], entries[1]) } } } ================================================ FILE: provider/webhook/api/httpapi.go ================================================ /* Copyright 2023 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 api import ( "context" "encoding/json" "net" "net/http" "time" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" log "github.com/sirupsen/logrus" ) const ( MediaTypeFormatAndVersion = "application/external.dns.webhook+json;version=1" ContentTypeHeader = "Content-Type" UrlAdjustEndpoints = "/adjustendpoints" UrlApplyChanges = "/applychanges" UrlRecords = "/records" ) type WebhookServer struct { Provider provider.Provider } func (p *WebhookServer) RecordsHandler(w http.ResponseWriter, req *http.Request) { switch req.Method { case http.MethodGet: records, err := p.Provider.Records(context.Background()) if err != nil { log.Errorf("Failed to get Records: %v", err) w.WriteHeader(http.StatusInternalServerError) return } w.Header().Set(ContentTypeHeader, MediaTypeFormatAndVersion) w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(records); err != nil { log.Errorf("Failed to encode records: %v", err) } return case http.MethodPost: var changes plan.Changes if err := json.NewDecoder(req.Body).Decode(&changes); err != nil { log.Errorf("Failed to decode changes: %v", err) w.WriteHeader(http.StatusBadRequest) return } err := p.Provider.ApplyChanges(context.Background(), &changes) if err != nil { log.Errorf("Failed to apply changes: %v", err) w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) return default: log.Errorf("Unsupported method %s", req.Method) w.WriteHeader(http.StatusBadRequest) } } func (p *WebhookServer) AdjustEndpointsHandler(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { log.Errorf("Unsupported method %s", req.Method) w.WriteHeader(http.StatusBadRequest) return } var pve []*endpoint.Endpoint if err := json.NewDecoder(req.Body).Decode(&pve); err != nil { log.Errorf("Failed to decode in adjustEndpointsHandler: %v", err) w.WriteHeader(http.StatusBadRequest) return } w.Header().Set(ContentTypeHeader, MediaTypeFormatAndVersion) pve, err := p.Provider.AdjustEndpoints(pve) if err != nil { log.Errorf("Failed to call adjust endpoints: %v", err) w.WriteHeader(http.StatusInternalServerError) } if err := json.NewEncoder(w).Encode(&pve); err != nil { log.Errorf("Failed to encode in adjustEndpointsHandler: %v", err) w.WriteHeader(http.StatusInternalServerError) return } } func (p *WebhookServer) NegotiateHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set(ContentTypeHeader, MediaTypeFormatAndVersion) err := json.NewEncoder(w).Encode(p.Provider.GetDomainFilter()) if err != nil { w.WriteHeader(http.StatusInternalServerError) } } // StartHTTPApi starts a HTTP server given any provider. // the function takes an optional channel as input which is used to signal that the server has started. // The server will listen on port `providerPort`. // The server will respond to the following endpoints: // - / (GET): initialization, negotiates headers and returns the domain filter // - /records (GET): returns the current records // - /records (POST): applies the changes // - /adjustendpoints (POST): executes the AdjustEndpoints method func StartHTTPApi(provider provider.Provider, startedChan chan struct{}, readTimeout, writeTimeout time.Duration, providerPort string) { p := WebhookServer{ Provider: provider, } m := http.NewServeMux() m.HandleFunc("/", p.NegotiateHandler) m.HandleFunc(UrlRecords, p.RecordsHandler) m.HandleFunc(UrlAdjustEndpoints, p.AdjustEndpointsHandler) s := &http.Server{ Addr: providerPort, Handler: m, ReadTimeout: readTimeout, WriteTimeout: writeTimeout, } l, err := net.Listen("tcp", providerPort) if err != nil { log.Fatal(err) } if startedChan != nil { startedChan <- struct{}{} } if err := s.Serve(l); err != nil { log.Fatal(err) } } ================================================ FILE: provider/webhook/api/httpapi_test.go ================================================ /* Copyright 2023 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 api import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) var records []*endpoint.Endpoint type FakeWebhookProvider struct { err error domainFilter *endpoint.DomainFilter assertChanges func(*plan.Changes) } func (p FakeWebhookProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { if p.err != nil { return nil, p.err } return records, nil } func (p FakeWebhookProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { if p.err != nil { return p.err } records = append(records, changes.Create...) if p.assertChanges != nil { p.assertChanges(changes) } return nil } func (p FakeWebhookProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { // for simplicity, we do not adjust endpoints in this test if p.err != nil { return nil, p.err } return endpoints, nil } func (p FakeWebhookProvider) GetDomainFilter() endpoint.DomainFilterInterface { return p.domainFilter } func TestMain(m *testing.M) { records = []*endpoint.Endpoint{ { DNSName: "foo.bar.com", RecordType: "A", }, } m.Run() } func TestRecordsHandlerRecords(t *testing.T) { req := httptest.NewRequest(http.MethodGet, UrlRecords, nil) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{ domainFilter: endpoint.NewDomainFilter([]string{"foo.bar.com"}), }, } providerAPIServer.RecordsHandler(w, req) res := w.Result() require.Equal(t, http.StatusOK, res.StatusCode) // require that the res has the same endpoints as the records slice defer res.Body.Close() require.NotNil(t, res.Body) var endpoints []*endpoint.Endpoint if err := json.NewDecoder(res.Body).Decode(&endpoints); err != nil { t.Errorf("Failed to decode response body: %s", err.Error()) } require.Equal(t, records, endpoints) } func TestRecordsHandlerRecordsWithErrors(t *testing.T) { req := httptest.NewRequest(http.MethodGet, UrlRecords, nil) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{ err: fmt.Errorf("error"), }, } providerAPIServer.RecordsHandler(w, req) res := w.Result() require.Equal(t, http.StatusInternalServerError, res.StatusCode) } func TestRecordsHandlerApplyChangesWithBadRequest(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/applychanges", nil) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{}, } providerAPIServer.RecordsHandler(w, req) res := w.Result() require.Equal(t, http.StatusBadRequest, res.StatusCode) } func TestRecordsHandlerApplyChangesWithValidRequest(t *testing.T) { changes := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "foo.bar.com", RecordType: "A", Targets: endpoint.Targets{}, }, }, } j, err := json.Marshal(changes) require.NoError(t, err) reader := bytes.NewReader(j) req := httptest.NewRequest(http.MethodPost, UrlApplyChanges, reader) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{}, } providerAPIServer.RecordsHandler(w, req) res := w.Result() require.Equal(t, http.StatusNoContent, res.StatusCode) } func TestRecordsHandlerApplyChangesWithErrors(t *testing.T) { changes := &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "foo.bar.com", RecordType: "A", Targets: endpoint.Targets{}, }, }, } j, err := json.Marshal(changes) require.NoError(t, err) reader := bytes.NewReader(j) req := httptest.NewRequest(http.MethodPost, UrlApplyChanges, reader) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{ err: fmt.Errorf("error"), }, } providerAPIServer.RecordsHandler(w, req) res := w.Result() require.Equal(t, http.StatusInternalServerError, res.StatusCode) } func TestRecordsHandlerWithWrongHTTPMethod(t *testing.T) { req := httptest.NewRequest(http.MethodPut, UrlRecords, nil) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{}, } providerAPIServer.RecordsHandler(w, req) res := w.Result() require.Equal(t, http.StatusBadRequest, res.StatusCode) } func TestRecordsHandlerWithMixedCase(t *testing.T) { input := `{"Create":[{"dnsName":"foo"}],"updateOld":[{"dnsName":"bar"}],"updateNew":[{"dnsName":"baz"}],"Delete":[{"dnsName":"qux"}]}` req := httptest.NewRequest(http.MethodPost, UrlRecords, strings.NewReader(input)) w := httptest.NewRecorder() records = []*endpoint.Endpoint{} providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{ assertChanges: func(changes *plan.Changes) { t.Helper() require.Equal(t, []*endpoint.Endpoint{ { DNSName: "foo", }, }, changes.Create) require.Equal(t, []*endpoint.Endpoint{ { DNSName: "bar", }, }, changes.UpdateOld) require.Equal(t, []*endpoint.Endpoint{ { DNSName: "qux", }, }, changes.Delete) }, }, } providerAPIServer.RecordsHandler(w, req) res := w.Result() require.Equal(t, http.StatusNoContent, res.StatusCode) assert.Len(t, records, 1) } func TestAdjustEndpointsHandlerWithInvalidRequest(t *testing.T) { req := httptest.NewRequest(http.MethodPost, UrlAdjustEndpoints, nil) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{}, } providerAPIServer.AdjustEndpointsHandler(w, req) res := w.Result() require.Equal(t, http.StatusBadRequest, res.StatusCode) req = httptest.NewRequest(http.MethodGet, UrlAdjustEndpoints, nil) providerAPIServer.AdjustEndpointsHandler(w, req) res = w.Result() require.Equal(t, http.StatusBadRequest, res.StatusCode) } func TestAdjustEndpointsHandlerWithValidRequest(t *testing.T) { pve := []*endpoint.Endpoint{ { DNSName: "foo.bar.com", RecordType: "A", Targets: endpoint.Targets{}, RecordTTL: 0, }, } j, err := json.Marshal(pve) require.NoError(t, err) reader := bytes.NewReader(j) req := httptest.NewRequest(http.MethodPost, UrlAdjustEndpoints, reader) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{}, } providerAPIServer.AdjustEndpointsHandler(w, req) res := w.Result() require.Equal(t, http.StatusOK, res.StatusCode) require.NotNil(t, res.Body) } func TestAdjustEndpointsHandlerWithError(t *testing.T) { pve := []*endpoint.Endpoint{ { DNSName: "foo.bar.com", RecordType: "A", Targets: endpoint.Targets{}, RecordTTL: 0, }, } j, err := json.Marshal(pve) require.NoError(t, err) reader := bytes.NewReader(j) req := httptest.NewRequest(http.MethodPost, UrlAdjustEndpoints, reader) w := httptest.NewRecorder() providerAPIServer := &WebhookServer{ Provider: &FakeWebhookProvider{ err: fmt.Errorf("error"), }, } providerAPIServer.AdjustEndpointsHandler(w, req) res := w.Result() require.Equal(t, http.StatusInternalServerError, res.StatusCode) require.NotNil(t, res.Body) } func TestStartHTTPApi(t *testing.T) { startedChan := make(chan struct{}) go StartHTTPApi(FakeWebhookProvider{}, startedChan, 5*time.Second, 10*time.Second, "127.0.0.1:8887") <-startedChan resp, err := http.Get("http://127.0.0.1:8887") require.NoError(t, err) // check that resp has a valid domain filter defer resp.Body.Close() df := endpoint.DomainFilter{} b, err := io.ReadAll(resp.Body) require.NoError(t, err) require.NoError(t, df.UnmarshalJSON(b)) } func TestNegotiateHandler_Success(t *testing.T) { provider := &FakeWebhookProvider{ domainFilter: endpoint.NewDomainFilter([]string{"foo.bar.com"}), } server := &WebhookServer{Provider: provider} w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) server.NegotiateHandler(w, req) res := w.Result() defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) require.Equal(t, MediaTypeFormatAndVersion, res.Header.Get(ContentTypeHeader)) df := &endpoint.DomainFilter{} body, err := io.ReadAll(res.Body) require.NoError(t, err) require.NoError(t, df.UnmarshalJSON(body)) require.Equal(t, provider.domainFilter, df) } func TestNegotiateHandler_FiltersWithSpecialEncodings(t *testing.T) { provider := &FakeWebhookProvider{ domainFilter: endpoint.NewDomainFilter([]string{"\\u001a", "\\Xfoo.\\u2028, \\u0000.com", ""}), } server := &WebhookServer{Provider: provider} w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) server.NegotiateHandler(w, req) res := w.Result() defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) } ================================================ FILE: provider/webhook/webhook.go ================================================ /* Copyright 2023 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 webhook import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/url" "time" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" extdnshttp "sigs.k8s.io/external-dns/pkg/http" "sigs.k8s.io/external-dns/pkg/metrics" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" webhookapi "sigs.k8s.io/external-dns/provider/webhook/api" "github.com/cenkalti/backoff/v5" "github.com/prometheus/client_golang/prometheus" log "github.com/sirupsen/logrus" ) const ( acceptHeader = "Accept" maxRetries = 5 ) var ( recordsErrorsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "webhook_provider", Name: "records_errors_total", Help: "Errors with Records method", }, ) recordsRequestsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "webhook_provider", Name: "records_requests_total", Help: "Requests with Records method", }, ) applyChangesErrorsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "webhook_provider", Name: "applychanges_errors_total", Help: "Errors with ApplyChanges method", }, ) applyChangesRequestsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "webhook_provider", Name: "applychanges_requests_total", Help: "Requests with ApplyChanges method", }, ) adjustEndpointsErrorsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "webhook_provider", Name: "adjustendpoints_errors_total", Help: "Errors with AdjustEndpoints method", }, ) adjustEndpointsRequestsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ Subsystem: "webhook_provider", Name: "adjustendpoints_requests_total", Help: "Requests with AdjustEndpoints method", }, ) ) type WebhookProvider struct { client *http.Client remoteServerURL *url.URL DomainFilter *endpoint.DomainFilter } func init() { metrics.RegisterMetric.MustRegister(recordsErrorsGauge) metrics.RegisterMetric.MustRegister(recordsRequestsGauge) metrics.RegisterMetric.MustRegister(applyChangesErrorsGauge) metrics.RegisterMetric.MustRegister(applyChangesRequestsGauge) metrics.RegisterMetric.MustRegister(adjustEndpointsErrorsGauge) metrics.RegisterMetric.MustRegister(adjustEndpointsRequestsGauge) } // New creates a webhook provider from the given configuration. func New(ctx context.Context, cfg *externaldns.Config, _ *endpoint.DomainFilter) (provider.Provider, error) { return newProvider(ctx, cfg.WebhookProviderURL, cfg.WebhookProviderReadTimeout, cfg.WebhookProviderWriteTimeout) } func newProvider(ctx context.Context, u string, readTimeout, writeTimeout time.Duration) (*WebhookProvider, error) { parsedURL, err := url.Parse(u) if err != nil { return nil, err } // covers the entire round-trip — writing the request body + waiting for + reading the response client := &http.Client{Timeout: readTimeout + writeTimeout} // negotiate API information req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion) resp, err := requestWithRetry(client, req) if err != nil { return nil, fmt.Errorf("failed to connect to webhook: %w", err) } defer extdnshttp.DrainAndClose(resp.Body) if ct := resp.Header.Get(webhookapi.ContentTypeHeader); ct != webhookapi.MediaTypeFormatAndVersion { return nil, fmt.Errorf("wrong content type returned from server: %s", ct) } df := &endpoint.DomainFilter{} if err := json.NewDecoder(resp.Body).Decode(df); err != nil { return nil, fmt.Errorf("failed to unmarshal response body of DomainFilter: %w", err) } return &WebhookProvider{ client: client, remoteServerURL: parsedURL, DomainFilter: df, }, nil } func requestWithRetry(client *http.Client, req *http.Request) (*http.Response, error) { resp, err := backoff.Retry(req.Context(), func() (*http.Response, error) { // Reset the body before each attempt so retries send the full payload. // GetBody is set automatically by http.NewRequest for in-memory body types; // it is nil for GET requests (no body), so the block is safely skipped. if req.GetBody != nil { body, err := req.GetBody() if err != nil { return nil, backoff.Permanent(fmt.Errorf("failed to reset request body: %w", err)) } req.Body = body } resp, err := client.Do(req) if err != nil { log.Debugf("Failed to connect to webhook: %v", err) return nil, err } // 5xx: retryable server error if resp.StatusCode >= http.StatusInternalServerError { extdnshttp.DrainAndClose(resp.Body) return nil, fmt.Errorf("server error: status code %d", resp.StatusCode) } // 3xx/4xx: permanent error, not worth retrying. // Note: http.Client follows redirects automatically, so a 3xx here means // either a non-redirect 3xx (e.g. 304) or a custom CheckRedirect policy; // it does not mean a normal redirect was left unhandled. // we currently only use 200 as success, but considering okay all 2XX for future usage if resp.StatusCode >= http.StatusMultipleChoices { extdnshttp.DrainAndClose(resp.Body) return nil, backoff.Permanent(fmt.Errorf("unexpected status code %d", resp.StatusCode)) } return resp, nil }, backoff.WithMaxTries(maxRetries)) return resp, err } // Records will make a GET call to remoteServerURL/records and return the results func (p WebhookProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { recordsRequestsGauge.Gauge.Inc() u := p.remoteServerURL.JoinPath("records").String() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { recordsErrorsGauge.Gauge.Inc() log.Debugf("Failed to create request: %s", err.Error()) return nil, err } req.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion) resp, err := p.client.Do(req) if err != nil { recordsErrorsGauge.Gauge.Inc() log.Debugf("Failed to perform request: %s", err.Error()) return nil, err } defer extdnshttp.DrainAndClose(resp.Body) if resp.StatusCode != http.StatusOK { recordsErrorsGauge.Gauge.Inc() log.Debugf("Failed to get records with code %d", resp.StatusCode) err := fmt.Errorf("failed to get records with code %d", resp.StatusCode) if isRetryableError(resp.StatusCode) { return nil, provider.NewSoftError(err) } return nil, err } var endpoints []*endpoint.Endpoint if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil { recordsErrorsGauge.Gauge.Inc() log.Debugf("Failed to decode response body: %s", err.Error()) return nil, err } return endpoints, nil } // ApplyChanges will make a POST to remoteServerURL/records with the changes func (p WebhookProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { applyChangesRequestsGauge.Gauge.Inc() u := p.remoteServerURL.JoinPath(webhookapi.UrlRecords).String() b := new(bytes.Buffer) if err := json.NewEncoder(b).Encode(changes); err != nil { applyChangesErrorsGauge.Gauge.Inc() log.Debugf("Failed to encode changes: %s", err.Error()) return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, b) if err != nil { applyChangesErrorsGauge.Gauge.Inc() log.Debugf("Failed to create request: %s", err.Error()) return err } req.Header.Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) resp, err := p.client.Do(req) if err != nil { applyChangesErrorsGauge.Gauge.Inc() log.Debugf("Failed to perform request: %s", err.Error()) return err } defer extdnshttp.DrainAndClose(resp.Body) if resp.StatusCode != http.StatusNoContent { applyChangesErrorsGauge.Gauge.Inc() log.Debugf("Failed to apply changes with code %d", resp.StatusCode) err := fmt.Errorf("failed to apply changes with code %d", resp.StatusCode) if isRetryableError(resp.StatusCode) { return provider.NewSoftError(err) } return err } return nil } // AdjustEndpoints will call the provider doing a POST on `/adjustendpoints` which will return a list of modified endpoints // based on a provider-specific requirement. // This method returns an empty slice in case there is a technical error on the provider's side so that no endpoints will be considered. func (p WebhookProvider) AdjustEndpoints(e []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { adjustEndpointsRequestsGauge.Gauge.Inc() var endpoints []*endpoint.Endpoint u, err := url.JoinPath(p.remoteServerURL.String(), webhookapi.UrlAdjustEndpoints) if err != nil { adjustEndpointsErrorsGauge.Gauge.Inc() log.Debugf("Failed to join path, %s", err) return nil, err } b := new(bytes.Buffer) if err := json.NewEncoder(b).Encode(e); err != nil { adjustEndpointsErrorsGauge.Gauge.Inc() log.Debugf("Failed to encode endpoints, %s", err) return nil, err } req, err := http.NewRequest(http.MethodPost, u, b) if err != nil { adjustEndpointsErrorsGauge.Gauge.Inc() log.Debugf("Failed to create new HTTP request, %s", err) return nil, err } req.Header.Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) req.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion) resp, err := p.client.Do(req) if err != nil { adjustEndpointsErrorsGauge.Gauge.Inc() log.Debugf("Failed executing http request, %s", err) return nil, err } // drainAndClose is deferred here and runs after json.Decode below consumes the // success-path body, so the drain only sees any trailing bytes left unread. defer extdnshttp.DrainAndClose(resp.Body) if resp.StatusCode != http.StatusOK { adjustEndpointsErrorsGauge.Gauge.Inc() log.Debugf("Failed to AdjustEndpoints with code %d", resp.StatusCode) err := fmt.Errorf("failed to AdjustEndpoints with code %d", resp.StatusCode) if isRetryableError(resp.StatusCode) { return nil, provider.NewSoftError(err) } return nil, err } if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil { adjustEndpointsErrorsGauge.Gauge.Inc() log.Debugf("Failed to decode response body: %s", err.Error()) return nil, err } return endpoints, nil } // GetDomainFilter make calls to get the serialized version of the domain filter func (p WebhookProvider) GetDomainFilter() endpoint.DomainFilterInterface { return p.DomainFilter } // isRetryableError returns true for HTTP status codes between 500 and 510 (inclusive) func isRetryableError(statusCode int) bool { return statusCode >= http.StatusInternalServerError && statusCode <= http.StatusNotExtended } ================================================ FILE: provider/webhook/webhook_test.go ================================================ /* Copyright 2023 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 webhook import ( "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" webhookapi "sigs.k8s.io/external-dns/provider/webhook/api" ) const ( testReadTimeout = 5 * time.Millisecond testWriteTimeout = 10 * time.Millisecond ) func TestNewWebhookProvider_InvalidURL(t *testing.T) { _, err := newProvider(t.Context(), "://invalid-url", testReadTimeout, testWriteTimeout) require.Error(t, err) } func TestNewWebhookProvider_HTTPRequestFailure(t *testing.T) { _, err := newProvider(t.Context(), "http://nonexistent.url", testReadTimeout, testWriteTimeout) require.Error(t, err) } func TestNewWebhookProvider_InvalidResponseBody(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.WriteHeader(http.StatusOK) w.Write([]byte("invalid-json")) // Invalid JSON })) defer svr.Close() _, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.Error(t, err) require.Contains(t, err.Error(), "failed to unmarshal response body of DomainFilter") } func TestNewWebhookProvider_Non2XXStatusCode(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) })) defer svr.Close() _, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.Error(t, err) require.Contains(t, err.Error(), "unexpected status code 400") } func TestNewWebhookProvider_WrongContentTypeHeader(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion+"wrong") _, _ = w.Write([]byte(`{}`)) return } })) defer svr.Close() _, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.Error(t, err) require.Contains(t, err.Error(), "wrong content type returned from server") } func TestInvalidDomainFilter(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.WriteHeader(http.StatusOK) return } w.Write([]byte(`[{ "dnsName" : "test.example.com" }]`)) })) defer svr.Close() _, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.Error(t, err) } func TestValidDomainfilter(t *testing.T) { // initialize domain filter domainFilter := endpoint.NewDomainFilter([]string{"example.com"}) svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) json.NewEncoder(w).Encode(domainFilter) return } })) defer svr.Close() p, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.NoError(t, err) require.Equal(t, p.GetDomainFilter(), endpoint.NewDomainFilter([]string{"example.com"})) } func TestRecords(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.Write([]byte(`{}`)) return } assert.Equal(t, "/records", r.URL.Path) w.Write([]byte(`[{ "dnsName" : "test.example.com" }]`)) })) defer svr.Close() provider, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.NoError(t, err) endpoints, err := provider.Records(t.Context()) require.NoError(t, err) require.NotNil(t, endpoints) require.Equal(t, []*endpoint.Endpoint{{ DNSName: "test.example.com", }}, endpoints) } func TestRecordsWithErrors(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.Write([]byte(`{}`)) return } assert.Equal(t, "/records", r.URL.Path) w.WriteHeader(http.StatusInternalServerError) })) defer svr.Close() p, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.NoError(t, err) _, err = p.Records(t.Context()) require.Error(t, err) require.ErrorIs(t, err, provider.SoftError) } func TestRecords_HTTPRequestErrorMissingHost0(t *testing.T) { wpr := WebhookProvider{ remoteServerURL: &url.URL{Scheme: "http", Host: "example\\x00.com", Path: "\\x00"}, client: &http.Client{}, } _, err := wpr.Records(t.Context()) require.Error(t, err) require.Contains(t, err.Error(), "invalid URL escape") } func TestRecords_HTTPRequestErrorMissingHost(t *testing.T) { wpr := WebhookProvider{ remoteServerURL: &url.URL{Host: "example.com", Path: "\\x00"}, client: &http.Client{}, } _, err := wpr.Records(t.Context()) require.Error(t, err) require.Contains(t, err.Error(), "unsupported protocol scheme") } func TestRecords_DecodeError(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == webhookapi.UrlRecords { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("invalid-json")) // Simulate invalid JSON response return } })) defer svr.Close() parsedURL, _ := url.Parse(svr.URL) p := WebhookProvider{ remoteServerURL: parsedURL, client: &http.Client{}, } _, err := p.Records(t.Context()) require.Error(t, err) require.Contains(t, err.Error(), "invalid character 'i' looking for beginning of value") } func TestRecords_NonOKStatusCode(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNetworkAuthenticationRequired) return })) defer svr.Close() parsedURL, _ := url.Parse(svr.URL) p := WebhookProvider{ remoteServerURL: &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host}, client: &http.Client{}, } _, err := p.Records(t.Context()) require.Error(t, err) assert.Contains(t, err.Error(), "failed to get records with code 511") } func TestApplyChanges(t *testing.T) { successfulApplyChanges := true svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.Write([]byte(`{}`)) return } assert.Equal(t, "/records", r.URL.Path) if successfulApplyChanges { w.WriteHeader(http.StatusNoContent) } else { w.WriteHeader(http.StatusInternalServerError) } })) defer svr.Close() p, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.NoError(t, err) err = p.ApplyChanges(t.Context(), nil) require.NoError(t, err) successfulApplyChanges = false err = p.ApplyChanges(t.Context(), nil) require.Error(t, err) require.ErrorIs(t, err, provider.SoftError) } func TestApplyChanges_HTTPNewRequestErrorWrongHost(t *testing.T) { wpr := WebhookProvider{ remoteServerURL: &url.URL{Host: "exa\\x00mple.com"}, client: &http.Client{}, } err := wpr.ApplyChanges(t.Context(), nil) require.Error(t, err) require.Contains(t, err.Error(), "invalid URL escape") } func TestApplyChanges_GetFailed(t *testing.T) { p := WebhookProvider{ remoteServerURL: &url.URL{Host: "localhost"}, client: &http.Client{}, } err := p.ApplyChanges(t.Context(), &plan.Changes{}) require.Error(t, err) assert.Contains(t, err.Error(), "unsupported protocol scheme") } func TestApplyChanges_StatusCodeError(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.Write([]byte(`{}`)) return } assert.Equal(t, webhookapi.UrlRecords, r.URL.Path) w.WriteHeader(http.StatusNetworkAuthenticationRequired) })) defer svr.Close() p, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.NoError(t, err) err = p.ApplyChanges(t.Context(), nil) require.Error(t, err) require.NotErrorIs(t, err, provider.SoftError) assert.Contains(t, err.Error(), "failed to apply changes with code 511") } func TestAdjustEndpoints(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.Write([]byte(`{}`)) return } assert.Equal(t, webhookapi.UrlAdjustEndpoints, r.URL.Path) var endpoints []*endpoint.Endpoint defer r.Body.Close() b, err := io.ReadAll(r.Body) if err != nil { t.Fatal(err) } err = json.Unmarshal(b, &endpoints) if err != nil { t.Fatal(err) } for _, e := range endpoints { e.RecordTTL = 0 } j, _ := json.Marshal(endpoints) w.Write(j) })) defer svr.Close() provider, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.NoError(t, err) endpoints := []*endpoint.Endpoint{ { DNSName: "test.example.com", RecordTTL: 10, RecordType: "A", Targets: endpoint.Targets{ "", }, }, } adjustedEndpoints, err := provider.AdjustEndpoints(endpoints) require.NoError(t, err) require.Equal(t, []*endpoint.Endpoint{{ DNSName: "test.example.com", RecordTTL: 0, RecordType: "A", Targets: endpoint.Targets{ "", }, }}, adjustedEndpoints) } func TestAdjustendpointsWithError(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.Write([]byte(`{}`)) return } assert.Equal(t, webhookapi.UrlAdjustEndpoints, r.URL.Path) w.WriteHeader(http.StatusInternalServerError) })) defer svr.Close() p, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.NoError(t, err) endpoints := []*endpoint.Endpoint{ { DNSName: "test.example.com", RecordTTL: 10, RecordType: "A", Targets: endpoint.Targets{ "", }, }, } _, err = p.AdjustEndpoints(endpoints) require.Error(t, err) require.ErrorIs(t, err, provider.SoftError) } // test apply changes with an endpoint with a provider specific property func TestApplyChangesWithProviderSpecificProperty(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.Write([]byte(`{}`)) return } if r.URL.Path == "/records" { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) // assert that the request contains the provider-specific property var changes plan.Changes defer r.Body.Close() b, err := io.ReadAll(r.Body) assert.NoError(t, err) err = json.Unmarshal(b, &changes) assert.NoError(t, err) assert.Len(t, changes.Create, 1) assert.Len(t, changes.Create[0].ProviderSpecific, 1) assert.Equal(t, "prop1", changes.Create[0].ProviderSpecific[0].Name) assert.Equal(t, "value1", changes.Create[0].ProviderSpecific[0].Value) w.WriteHeader(http.StatusNoContent) return } })) defer svr.Close() p, err := newProvider(t.Context(), svr.URL, testReadTimeout, testWriteTimeout) require.NoError(t, err) e := &endpoint.Endpoint{ DNSName: "test.example.com", RecordTTL: 10, RecordType: "A", Targets: endpoint.Targets{ "", }, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "prop1", Value: "value1", }, }, } err = p.ApplyChanges(t.Context(), &plan.Changes{ Create: []*endpoint.Endpoint{ e, }, }) require.NoError(t, err) } func TestAdjustEndpoints_JoinPathError(t *testing.T) { wpr := WebhookProvider{ remoteServerURL: &url.URL{Scheme: "http", Host: "example\\x00.com"}, } _, err := wpr.AdjustEndpoints(nil) require.Error(t, err) require.Contains(t, err.Error(), "invalid URL escape") } func TestAdjustEndpoints_HTTPRequestErrorMissingHost(t *testing.T) { wpr := WebhookProvider{ remoteServerURL: &url.URL{Host: "example.com", Path: "\\x00"}, client: &http.Client{}, } _, err := wpr.AdjustEndpoints(nil) require.Error(t, err) require.Contains(t, err.Error(), "unsupported protocol scheme") // Ensure the "BINGO" log is triggered } func TestAdjustEndpoints_NonOKStatusCode(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNetworkAuthenticationRequired) return })) defer svr.Close() parsedURL, _ := url.Parse(svr.URL) p := WebhookProvider{ remoteServerURL: &url.URL{Scheme: parsedURL.Scheme, Host: parsedURL.Host}, client: &http.Client{}, } endpoints := []*endpoint.Endpoint{ { DNSName: "test.example.com", RecordTTL: 10, RecordType: "A", Targets: endpoint.Targets{""}, }, } _, err := p.AdjustEndpoints(endpoints) require.Error(t, err) assert.Contains(t, err.Error(), "failed to AdjustEndpoints with code 511") } func TestAdjustEndpoints_DecodeError(t *testing.T) { svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == webhookapi.UrlAdjustEndpoints { w.Header().Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("invalid-json")) // Simulate invalid JSON response return } })) defer svr.Close() parsedURL, _ := url.Parse(svr.URL) p := WebhookProvider{ remoteServerURL: parsedURL, client: &http.Client{}, } var endpoints []*endpoint.Endpoint _, err := p.AdjustEndpoints(endpoints) require.Error(t, err) require.Contains(t, err.Error(), "invalid character 'i' looking for beginning of value") } func TestRequestWithRetry_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) io.WriteString(w, "ok") })) defer server.Close() client := &http.Client{Timeout: 2 * time.Second} req, err := http.NewRequest(http.MethodGet, server.URL, nil) require.NoError(t, err) resp, err := requestWithRetry(client, req) require.NoError(t, err) require.NotNil(t, resp) require.Equal(t, http.StatusOK, resp.StatusCode) } func TestRequestWithRetry_NonRetriableStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) })) defer server.Close() client := &http.Client{Timeout: 2 * time.Second} req, err := http.NewRequest(http.MethodGet, server.URL, nil) require.NoError(t, err) resp, err := requestWithRetry(client, req) require.Error(t, err) require.Nil(t, resp) } func TestRequestWithRetry_ServerErrorRetried(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { attempts++ if attempts < 3 { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) io.WriteString(w, "ok") })) defer server.Close() client := &http.Client{Timeout: 2 * time.Second} req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil) require.NoError(t, err) resp, err := requestWithRetry(client, req) require.NoError(t, err) require.NotNil(t, resp) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, 3, attempts) } ================================================ FILE: provider/zone_id_filter.go ================================================ /* Copyright 2017 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 provider import "strings" // ZoneIDFilter holds a list of zone ids to filter by type ZoneIDFilter struct { ZoneIDs []string } // NewZoneIDFilter returns a new ZoneIDFilter given a list of zone ids func NewZoneIDFilter(zoneIDs []string) ZoneIDFilter { return ZoneIDFilter{zoneIDs} } // Match checks whether a zone matches one of the provided zone ids func (f ZoneIDFilter) Match(zoneID string) bool { // An empty filter includes all zones. if len(f.ZoneIDs) == 0 { return true } if len(f.ZoneIDs) == 1 && f.ZoneIDs[0] == "" { return true } for _, id := range f.ZoneIDs { if strings.HasSuffix(zoneID, id) { return true } } return false } // IsConfigured returns true if DomainFilter is configured, false otherwise func (f ZoneIDFilter) IsConfigured() bool { if len(f.ZoneIDs) == 1 { return f.ZoneIDs[0] != "" } return len(f.ZoneIDs) > 0 } ================================================ FILE: provider/zone_id_filter_test.go ================================================ /* Copyright 2017 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 provider import ( "testing" "github.com/stretchr/testify/assert" ) type zoneIDFilterTest struct { zoneIDFilter []string zone string expected bool } type zoneIdFilterTestIsConfigured struct { zoneIDFilter []string expected bool } func TestZoneIDFilterMatch(t *testing.T) { zone := "/hostedzone/ZTST1" for _, tt := range []zoneIDFilterTest{ { []string{}, zone, true, }, { []string{""}, zone, true, }, { []string{"/hostedzone/ZTST1"}, zone, true, }, { []string{"/hostedzone/ZTST2"}, zone, false, }, { []string{"ZTST1"}, zone, true, }, { []string{"ZTST2"}, zone, false, }, { []string{"/hostedzone/ZTST1", "/hostedzone/ZTST2"}, zone, true, }, { []string{"/hostedzone/ZTST2", "/hostedzone/ZTST3"}, zone, false, }, { []string{"/hostedzone/ZTST2", "/hostedzone/ZTST1"}, zone, true, }, } { zoneIDFilter := NewZoneIDFilter(tt.zoneIDFilter) assert.Equal(t, tt.expected, zoneIDFilter.Match(tt.zone)) } } func TestZoneIDFilterIsConfigured(t *testing.T) { for _, tt := range []zoneIdFilterTestIsConfigured{ { []string{""}, false, }, { []string{}, false, }, { []string{"/hostedzone/ZTST2"}, true, }, { []string{"/hostedzone/ZTST2", "hostedzone/ZTST2"}, true, }, { []string{"/ZSTS2"}, true, }, } { zoneIDFilter := NewZoneIDFilter(tt.zoneIDFilter) assert.Equal(t, tt.expected, zoneIDFilter.IsConfigured()) } } ================================================ FILE: provider/zone_tag_filter.go ================================================ /* Copyright 2017 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 provider import ( "strings" ) // ZoneTagFilter holds a list of zone tags to filter by type ZoneTagFilter struct { tagsMap map[string]string } // NewZoneTagFilter returns a new ZoneTagFilter given a list of zone tags func NewZoneTagFilter(tags []string) ZoneTagFilter { if len(tags) == 1 && len(tags[0]) == 0 { tags = []string{} } tagsMap := make(map[string]string) // tags pre-processing, to make sure the pre-processing is not happening at the time of filtering for _, tag := range tags { parts := strings.SplitN(tag, "=", 2) key := strings.TrimSpace(parts[0]) if key == "" { continue } if len(parts) == 2 { value := strings.TrimSpace(parts[1]) tagsMap[key] = value } else { tagsMap[key] = "" } } return ZoneTagFilter{tagsMap: tagsMap} } // Match checks whether a zone's set of tags matches the provided tag values func (f ZoneTagFilter) Match(tagsMap map[string]string) bool { for key, v := range f.tagsMap { if value, hasTag := tagsMap[key]; !hasTag || (v != "" && value != v) { return false } } return true } // IsEmpty returns true if there are no tags for the filter func (f ZoneTagFilter) IsEmpty() bool { return len(f.tagsMap) == 0 } ================================================ FILE: provider/zone_tag_filter_test.go ================================================ /* Copyright 2017 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 provider import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) var basicZoneTags = []struct { name string tagsFilter []string zoneTags map[string]string matches bool }{ { "single tag no match", []string{"tag1=value1"}, map[string]string{"tag0": "value0"}, false, }, { "single tag matches", []string{"tag1=value1"}, map[string]string{"tag1": "value1"}, true, }, { "multiple tags no value match", []string{"tag1=value1"}, map[string]string{"tag0": "value0", "tag1": "value2"}, false, }, { "multiple tags matches", []string{"tag1=value1"}, map[string]string{"tag0": "value0", "tag1": "value1"}, true, }, { "tag name no match", []string{"tag1"}, map[string]string{"tag0": "value0"}, false, }, { "tag name matches", []string{"tag1"}, map[string]string{"tag1": "value1"}, true, }, { "multiple filter no match", []string{"tag1=value1", "tag2=value2"}, map[string]string{"tag1": "value1"}, false, }, { "multiple filter matches", []string{"tag1=value1", "tag2=value2"}, map[string]string{"tag2": "value2", "tag1": "value1", "tag3": "value3"}, true, }, { "empty tag filter matches all", []string{""}, map[string]string{"tag0": "value0"}, true, }, { "tag filter without key and equal sign", []string{"tag1=value1", "=haha"}, map[string]string{"tag1": "value1"}, true, }, } func TestZoneTagFilterMatch(t *testing.T) { for _, tc := range basicZoneTags { zoneTagFilter := NewZoneTagFilter(tc.tagsFilter) t.Run(tc.name, func(t *testing.T) { assert.Equal(t, tc.matches, zoneTagFilter.Match(tc.zoneTags)) }) } } func TestZoneTagFilterNotSupportedFormat(t *testing.T) { tests := []struct { desc string tags []string want map[string]string }{ {desc: "multiple or separate values with commas", tags: []string{"key1=val1,key2=val2"}, want: map[string]string{"key1": "val1,key2=val2"}}, {desc: "exclude tag", tags: []string{"!key1"}, want: map[string]string{"!key1": ""}}, {desc: "exclude tags", tags: []string{"!key1=val"}, want: map[string]string{"!key1": "val"}}, {desc: "key is empty", tags: []string{"=val"}, want: map[string]string{}}, } for _, tc := range tests { t.Run(fmt.Sprintf("%s", tc.desc), func(t *testing.T) { got := NewZoneTagFilter(tc.tags) assert.Equal(t, tc.want, got.tagsMap) }) } } func TestZoneTagFilterMatchGeneratedValues(t *testing.T) { tests := []struct { filters int zones int source filterZoneTags }{ {10, 30, generateTagFilterAndZoneTagsForMatch(10, 30)}, {5, 40, generateTagFilterAndZoneTagsForMatch(5, 40)}, {30, 50, generateTagFilterAndZoneTagsForMatch(30, 50)}, } for _, tc := range tests { t.Run(fmt.Sprintf("filters:%d zones:%d", tc.filters, tc.zones), func(t *testing.T) { assert.True(t, tc.source.ZoneTagFilter.Match(tc.source.inputTags)) }) } } func TestZoneTagFilterNotMatchGeneratedValues(t *testing.T) { tests := []struct { filters int zones int source filterZoneTags }{ {10, 30, generateTagFilterAndZoneTagsForNotMatch(10, 30)}, {5, 40, generateTagFilterAndZoneTagsForNotMatch(5, 40)}, {30, 50, generateTagFilterAndZoneTagsForNotMatch(30, 50)}, } for _, tc := range tests { t.Run(fmt.Sprintf("filters:%d zones:%d", tc.filters, tc.zones), func(t *testing.T) { assert.False(t, tc.source.ZoneTagFilter.Match(tc.source.inputTags)) }) } } // benchmarks func BenchmarkZoneTagFilterMatchBasic(b *testing.B) { for _, tc := range basicZoneTags { zoneTagFilter := NewZoneTagFilter(tc.tagsFilter) for b.Loop() { zoneTagFilter.Match(tc.zoneTags) } } } var benchFixtures = []struct { source filterZoneTags }{ // match {generateTagFilterAndZoneTagsForMatch(10, 30)}, {generateTagFilterAndZoneTagsForMatch(5, 40)}, {generateTagFilterAndZoneTagsForMatch(30, 50)}, // no match {generateTagFilterAndZoneTagsForNotMatch(10, 30)}, {generateTagFilterAndZoneTagsForNotMatch(5, 40)}, {generateTagFilterAndZoneTagsForNotMatch(30, 50)}, } func BenchmarkZoneTagFilterComplex(b *testing.B) { for _, tc := range benchFixtures { for b.Loop() { tc.source.ZoneTagFilter.Match(tc.source.inputTags) } } } // test doubles type filterZoneTags struct { ZoneTagFilter inputTags map[string]string } // generateTagFilterAndZoneTagsForMatch generates filter tags and zone tags that do match. func generateTagFilterAndZoneTagsForMatch(filter, zone int) filterZoneTags { return generateTagFilterAndZoneTags(filter, zone, true) } // generateTagFilterAndZoneTagsForNotMatch generates filter tags and zone tags that do not match. func generateTagFilterAndZoneTagsForNotMatch(filter, zone int) filterZoneTags { return generateTagFilterAndZoneTags(filter, zone, false) } // generateTagFilterAndZoneTags generates filter tags and zone tags based on the match parameter. func generateTagFilterAndZoneTags(filter, zone int, match bool) filterZoneTags { validate(filter, zone) toFilterTags := make([]string, 0, filter) inputTags := make(map[string]string, zone) for i := range filter { tagIndex := i if !match { tagIndex += 50 } toFilterTags = append(toFilterTags, fmt.Sprintf("tag-%d=value-%d", tagIndex, i)) } for i := range zone { tagIndex := i if !match { // Make sure the input tags are different from the filter tags tagIndex += 2 } inputTags[fmt.Sprintf("tag-%d", i)] = fmt.Sprintf("value-%d", tagIndex) } return filterZoneTags{NewZoneTagFilter(toFilterTags), inputTags} } func validate(filter int, zone int) { if zone > 50 { panic("zone number is too high") } if filter > zone { panic("filter number is too high") } } ================================================ FILE: provider/zone_type_filter.go ================================================ /* Copyright 2017 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 provider import ( route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" ) const ( zoneTypePublic = "public" zoneTypePrivate = "private" ) // ZoneTypeFilter holds a zone type to filter for. type ZoneTypeFilter struct { zoneType string } // NewZoneTypeFilter returns a new ZoneTypeFilter given a zone type to filter for. func NewZoneTypeFilter(zoneType string) ZoneTypeFilter { return ZoneTypeFilter{zoneType: zoneType} } // Match checks whether a zone matches the zone type that's filtered for. func (f ZoneTypeFilter) Match(rawZoneType any) bool { // An empty zone filter includes all hosted zones. if f.zoneType == "" { return true } switch zoneType := rawZoneType.(type) { // Given a zone type we return true if the given zone matches this type. case string: switch f.zoneType { case zoneTypePublic: return zoneType == zoneTypePublic case zoneTypePrivate: return zoneType == zoneTypePrivate } case route53types.HostedZone: // If the zone has no config we assume it's a public zone since the config's field // `PrivateZone` is false by default in go. if zoneType.Config == nil { return f.zoneType == zoneTypePublic } switch f.zoneType { case zoneTypePublic: return !zoneType.Config.PrivateZone case zoneTypePrivate: return zoneType.Config.PrivateZone } } // We return false on any other path, e.g. unknown zone type filter value. return false } ================================================ FILE: provider/zone_type_filter_test.go ================================================ /* Copyright 2017 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 provider import ( "testing" route53types "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/stretchr/testify/assert" ) func TestZoneTypeFilterMatch(t *testing.T) { publicZoneStr := "public" privateZoneStr := "private" publicZoneAWS := route53types.HostedZone{Config: &route53types.HostedZoneConfig{PrivateZone: false}} privateZoneAWS := route53types.HostedZone{Config: &route53types.HostedZoneConfig{PrivateZone: true}} for _, tc := range []struct { zoneTypeFilter string matches bool zones []any }{ { "", true, []any{publicZoneStr, privateZoneStr, route53types.HostedZone{}}, }, { "public", true, []any{publicZoneStr, publicZoneAWS, route53types.HostedZone{}}, }, { "public", false, []any{privateZoneStr, privateZoneAWS}, }, { "private", true, []any{privateZoneStr, privateZoneAWS}, }, { "private", false, []any{publicZoneStr, publicZoneAWS, route53types.HostedZone{}}, }, { "unknown", false, []any{publicZoneStr}, }, } { t.Run(tc.zoneTypeFilter, func(t *testing.T) { zoneTypeFilter := NewZoneTypeFilter(tc.zoneTypeFilter) for _, zone := range tc.zones { assert.Equal(t, tc.matches, zoneTypeFilter.Match(zone)) } }) } } ================================================ FILE: provider/zonefinder.go ================================================ /* Copyright 2017 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 provider import ( "strings" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/internal/idna" ) type ZoneIDName map[string]string func (z ZoneIDName) Add(zoneID, zoneName string) { var err error z[zoneID], err = idna.Profile.ToUnicode(zoneName) if err != nil { log.Warnf("failed to convert zonename %q to its Unicode form: %v", zoneName, err) z[zoneID] = zoneName } } // FindZone identifies the most suitable DNS zone for a given hostname. // It returns the zone ID and name that best match the hostname. // // The function processes the hostname by splitting it into labels and // converting each label to its Unicode form using IDNA (Internationalized // Domain Names for Applications) standards. // // Labels containing underscores ('_') are skipped during Unicode conversion. // This is because underscores are often used in special DNS records (e.g., // SRV records as per RFC 2782, or TXT record for services) that are not // IDNA-aware and cannot represent non-ASCII labels. Skipping these labels // ensures compatibility with such use cases. func (z ZoneIDName) FindZone(hostname string) (string, string) { var name string domainLabels := strings.Split(hostname, ".") for i, label := range domainLabels { if strings.Contains(label, "_") { continue } convertedLabel, err := idna.Profile.ToUnicode(label) if err != nil { log.Warnf("Failed to convert label %q of hostname %q to its Unicode form: %v", label, hostname, err) convertedLabel = label } domainLabels[i] = convertedLabel } name = strings.Join(domainLabels, ".") var suitableZoneID, suitableZoneName string for zoneID, zoneName := range z { if name == zoneName || strings.HasSuffix(name, "."+zoneName) { if suitableZoneName == "" || len(zoneName) > len(suitableZoneName) { suitableZoneID = zoneID suitableZoneName = zoneName } } } return suitableZoneID, suitableZoneName } ================================================ FILE: provider/zonefinder_test.go ================================================ /* Copyright 2017 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 provider import ( "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) func TestZoneIDName(t *testing.T) { z := ZoneIDName{} z.Add("123456", "foo.bar") z.Add("123456", "qux.baz") z.Add("654321", "foo.qux.baz") z.Add("987654", "エイミー.みんな") z.Add("123123", "_metadata.example.com") z.Add("1231231", "_foo._metadata.example.com") z.Add("456456", "_metadata.エイミー.みんな") z.Add("123412", "*.example.com") // adding a zone as punycode, see that it is injected as unicode/international format z.Add("234567", "xn--testcass-e1ae.fr") assert.Equal(t, ZoneIDName{ "123456": "qux.baz", "654321": "foo.qux.baz", "987654": "エイミー.みんな", "123123": "_metadata.example.com", "1231231": "_foo._metadata.example.com", "456456": "_metadata.エイミー.みんな", "123412": "*.example.com", "234567": "testécassé.fr", }, z) // simple entry in a domain zoneID, zoneName := z.FindZone("name.qux.baz") assert.Equal(t, "qux.baz", zoneName) assert.Equal(t, "123456", zoneID) // simple entry in a domain's subdomain. zoneID, zoneName = z.FindZone("name.foo.qux.baz") assert.Equal(t, "foo.qux.baz", zoneName) assert.Equal(t, "654321", zoneID) // no possible zone for entry zoneID, zoneName = z.FindZone("name.qux.foo") assert.Empty(t, zoneName) assert.Empty(t, zoneID) // no possible zone for entry of a substring to valid a zone zoneID, zoneName = z.FindZone("nomatch-foo.bar") assert.Empty(t, zoneName) assert.Empty(t, zoneID) // entry's suffix matches a subdomain but doesn't belong there zoneID, zoneName = z.FindZone("name-foo.qux.baz") assert.Equal(t, "qux.baz", zoneName) assert.Equal(t, "123456", zoneID) // entry is an exact match of the domain (e.g. azure provider) zoneID, zoneName = z.FindZone("foo.qux.baz") assert.Equal(t, "foo.qux.baz", zoneName) assert.Equal(t, "654321", zoneID) // entry gets normalized before finding zoneID, zoneName = z.FindZone("xn--eckh0ome.xn--q9jyb4c") assert.Equal(t, "エイミー.みんな", zoneName) assert.Equal(t, "987654", zoneID) zoneID, zoneName = z.FindZone("_foo._metadata.example.com") assert.Equal(t, "_foo._metadata.example.com", zoneName) assert.Equal(t, "1231231", zoneID) zoneID, zoneName = z.FindZone("*.example.com") assert.Equal(t, "*.example.com", zoneName) assert.Equal(t, "123412", zoneID) // looking for a zone that has been inserted as punycode zoneID, zoneName = z.FindZone("example.testécassé.fr") assert.Equal(t, "testécassé.fr", zoneName) assert.Equal(t, "234567", zoneID) zoneID, zoneName = z.FindZone("example.xn--testcass-e1ae.fr") assert.Equal(t, "testécassé.fr", zoneName) assert.Equal(t, "234567", zoneID) hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) _, _ = z.FindZone("xn--not-a-valid-punycode") logtest.TestHelperLogContains("Failed to convert label \"xn--not-a-valid-punycode\" of hostname \"xn--not-a-valid-punycode\" to its Unicode form: idna: invalid label", hook, t) } ================================================ FILE: registry/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - registry ================================================ FILE: registry/awssd/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - awssd ================================================ FILE: registry/awssd/registry.go ================================================ /* Copyright 2018 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 awssd import ( "context" "errors" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/registry" ) // AWSSDRegistry implements registry interface with ownership information associated via the Description field of SD Service type AWSSDRegistry struct { provider provider.Provider ownerID string } // New creates an AWSSDRegistry from the given configuration. func New(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) { return newRegistry(p, cfg.TXTOwnerID) } // newRegistry returns implementation of registry for AWS SD func newRegistry(provider provider.Provider, ownerID string) (*AWSSDRegistry, error) { if ownerID == "" { return nil, errors.New("owner id cannot be empty") } return &AWSSDRegistry{ provider: provider, ownerID: ownerID, }, nil } func (sdr *AWSSDRegistry) GetDomainFilter() endpoint.DomainFilterInterface { return sdr.provider.GetDomainFilter() } func (sdr *AWSSDRegistry) OwnerID() string { return sdr.ownerID } // Records calls AWS SD API and expects AWS SD provider to provider Owner/Resource information as a serialized // value in the AWSSDDescriptionLabel value in the Labels map func (sdr *AWSSDRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { records, err := sdr.provider.Records(ctx) if err != nil { return nil, err } for _, record := range records { labels, err := endpoint.NewLabelsFromStringPlain(record.Labels[endpoint.AWSSDDescriptionLabel]) if err != nil { // if we fail to parse the output then simply assume the endpoint is not managed by any instance of External DNS record.Labels = endpoint.NewLabels() continue } record.Labels = labels } return records, nil } // ApplyChanges filters out records not owned the External-DNS, additionally it adds the required label // inserted in the AWS SD instance as a CreateID field func (sdr *AWSSDRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { filteredChanges := &plan.Changes{ Create: changes.Create, UpdateNew: endpoint.FilterEndpointsByOwnerID(sdr.ownerID, changes.UpdateNew), UpdateOld: endpoint.FilterEndpointsByOwnerID(sdr.ownerID, changes.UpdateOld), Delete: endpoint.FilterEndpointsByOwnerID(sdr.ownerID, changes.Delete), } sdr.updateLabels(filteredChanges.Create) sdr.updateLabels(filteredChanges.UpdateNew) sdr.updateLabels(filteredChanges.UpdateOld) sdr.updateLabels(filteredChanges.Delete) return sdr.provider.ApplyChanges(ctx, filteredChanges) } func (sdr *AWSSDRegistry) updateLabels(endpoints []*endpoint.Endpoint) { for _, ep := range endpoints { if ep.Labels == nil { ep.Labels = make(map[string]string) } ep.Labels[endpoint.OwnerLabelKey] = sdr.ownerID ep.Labels[endpoint.AWSSDDescriptionLabel] = ep.Labels.SerializePlain(false) } } // AdjustEndpoints modifies the endpoints as needed by the specific provider func (sdr *AWSSDRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return sdr.provider.AdjustEndpoints(endpoints) } ================================================ FILE: registry/awssd/registry_test.go ================================================ /* Copyright 2018 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 awssd import ( "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) type inMemoryProvider struct { provider.BaseProvider endpoints []*endpoint.Endpoint onApplyChanges func(changes *plan.Changes) } func (p *inMemoryProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { return p.endpoints, nil } func (p *inMemoryProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { p.onApplyChanges(changes) return nil } func newInMemoryProvider(endpoints []*endpoint.Endpoint, onApplyChanges func(changes *plan.Changes)) *inMemoryProvider { return &inMemoryProvider{ endpoints: endpoints, onApplyChanges: onApplyChanges, } } func TestAWSSDRegistry_newRegistry(t *testing.T) { p := newInMemoryProvider(nil, nil) _, err := newRegistry(p, "") require.Error(t, err) _, err = newRegistry(p, "owner") require.NoError(t, err) } func TestAWSSDRegistryTest_Records(t *testing.T) { p := newInMemoryProvider([]*endpoint.Endpoint{ newEndpointWithOwnerAndDescription("foo1.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, "", ""), newEndpointWithOwnerAndDescription("foo2.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, "owner", "\"heritage=external-dns,external-dns/owner=owner\""), newEndpointWithOwnerAndDescription("foo3.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, "", ""), newEndpointWithOwnerAndDescription("foo4.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, "owner", "\"heritage=external-dns,external-dns/owner=owner\""), }, nil) expectedRecords := []*endpoint.Endpoint{ { DNSName: "foo1.test-zone.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "foo2.test-zone.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "foo3.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "foo4.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, } r, _ := newRegistry(p, "records-owner") records, _ := r.Records(t.Context()) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) } func TestAWSSDRegistry_Records_ApplyChanges(t *testing.T) { changes := &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("new-record-1.test-zone.example.org", endpoint.RecordTypeCNAME, "new-loadbalancer-1.lb.com"), }, Delete: []*endpoint.Endpoint{ endpoint.NewEndpoint("foobar.test-zone.example.org", endpoint.RecordTypeA, "1.2.3.4"). WithLabel(endpoint.OwnerLabelKey, "owner"), }, UpdateNew: []*endpoint.Endpoint{ endpoint.NewEndpoint("tar.test-zone.example.org", endpoint.RecordTypeCNAME, "new-tar.loadbalancer.com"). WithLabel(endpoint.OwnerLabelKey, "owner"), }, UpdateOld: []*endpoint.Endpoint{ endpoint.NewEndpoint("tar.test-zone.example.org", endpoint.RecordTypeCNAME, "tar.loadbalancer.com"). WithLabel(endpoint.OwnerLabelKey, "owner"), }, } expected := &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwnerAndDescription("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "\"heritage=external-dns,external-dns/owner=owner\""), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwnerAndDescription("foobar.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, "owner", "\"heritage=external-dns,external-dns/owner=owner\""), }, UpdateNew: []*endpoint.Endpoint{ newEndpointWithOwnerAndDescription("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "\"heritage=external-dns,external-dns/owner=owner\""), }, UpdateOld: []*endpoint.Endpoint{ newEndpointWithOwnerAndDescription("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner", "\"heritage=external-dns,external-dns/owner=owner\""), }, } p := newInMemoryProvider(nil, func(got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, "UpdateOld": expected.UpdateOld, "Delete": expected.Delete, } mGot := map[string][]*endpoint.Endpoint{ "Create": got.Create, "UpdateNew": got.UpdateNew, "UpdateOld": got.UpdateOld, "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) }) r, err := newRegistry(p, "owner") require.NoError(t, err) err = r.ApplyChanges(t.Context(), changes) require.NoError(t, err) } func newEndpointWithOwnerAndDescription(dnsName, target, recordType, ownerID string, description string) *endpoint.Endpoint { e := endpoint.NewEndpoint(dnsName, recordType, target) e.Labels[endpoint.OwnerLabelKey] = ownerID e.Labels[endpoint.AWSSDDescriptionLabel] = description return e } ================================================ FILE: registry/dynamodb/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - dynamodb ================================================ FILE: registry/dynamodb/registry.go ================================================ /* Copyright 2023 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 dynamodb import ( "context" b64 "encoding/base64" "errors" "fmt" "maps" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" awsdynamodb "github.com/aws/aws-sdk-go-v2/service/dynamodb" dynamodbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" provideraws "sigs.k8s.io/external-dns/provider/aws" "sigs.k8s.io/external-dns/registry" "sigs.k8s.io/external-dns/registry/mapper" ) // DynamoDBAPI is the subset of the AWS DynamoDB API that we actually use. Add methods as required. Signatures must match exactly. type DynamoDBAPI interface { DescribeTable(context.Context, *awsdynamodb.DescribeTableInput, ...func(*awsdynamodb.Options)) (*awsdynamodb.DescribeTableOutput, error) Scan(context.Context, *awsdynamodb.ScanInput, ...func(*awsdynamodb.Options)) (*awsdynamodb.ScanOutput, error) BatchExecuteStatement(context.Context, *awsdynamodb.BatchExecuteStatementInput, ...func(*awsdynamodb.Options)) (*awsdynamodb.BatchExecuteStatementOutput, error) } // DynamoDBRegistry implements registry interface with ownership implemented via an AWS DynamoDB table. type DynamoDBRegistry struct { provider provider.Provider ownerID string // refers to the owner id of the current instance dynamodbAPI DynamoDBAPI table string // For migration from TXT registry mapper mapper.NameMapper wildcardReplacement string managedRecordTypes []string excludeRecordTypes []string txtEncryptAESKey []byte // cache the dynamodb records owned by us. labels map[endpoint.EndpointKey]endpoint.Labels orphanedLabels sets.Set[endpoint.EndpointKey] // cache the records in memory and update on an interval instead. recordsCache []*endpoint.Endpoint recordsCacheRefreshTime time.Time cacheInterval time.Duration } const dynamodbAttributeMigrate = "dynamodb/needs-migration" // DynamoDB allows a maximum batch size of 25 items. var dynamodbMaxBatchSize uint8 = 25 // New creates a DynamoDBRegistry from the given configuration. func New(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) { client := awsdynamodb.NewFromConfig(provideraws.CreateDefaultV2Config(cfg), WithRegion(cfg.AWSDynamoDBRegion)) return newRegistry(p, cfg.TXTOwnerID, client, cfg.AWSDynamoDBTable, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, []byte(cfg.TXTEncryptAESKey), cfg.TXTCacheInterval) } // newRegistry returns a new DynamoDBRegistry object. func newRegistry( provider provider.Provider, ownerID string, dynamodbAPI DynamoDBAPI, table, txtPrefix, txtSuffix, txtWildcardReplacement string, managedRecordTypes, excludeRecordTypes []string, txtEncryptAESKey []byte, cacheInterval time.Duration) (*DynamoDBRegistry, error) { if ownerID == "" { return nil, errors.New("owner id cannot be empty") } if table == "" { return nil, errors.New("table cannot be empty") } // TODO: encryption logic duplicated in TXT registry; refactor into common utility function. if len(txtEncryptAESKey) == 0 { txtEncryptAESKey = nil } else if len(txtEncryptAESKey) != 32 { var err error if txtEncryptAESKey, err = b64.StdEncoding.DecodeString(string(txtEncryptAESKey)); err != nil || len(txtEncryptAESKey) != 32 { return nil, errors.New("the AES Encryption key must be 32 bytes long, in either plain text or base64-encoded format") } } if len(txtPrefix) > 0 && len(txtSuffix) > 0 { return nil, errors.New("txt-prefix and txt-suffix are mutually exclusive") } return &DynamoDBRegistry{ provider: provider, ownerID: ownerID, dynamodbAPI: dynamodbAPI, table: table, mapper: mapper.NewAffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement), wildcardReplacement: txtWildcardReplacement, managedRecordTypes: managedRecordTypes, excludeRecordTypes: excludeRecordTypes, txtEncryptAESKey: txtEncryptAESKey, cacheInterval: cacheInterval, }, nil } func (im *DynamoDBRegistry) GetDomainFilter() endpoint.DomainFilterInterface { return im.provider.GetDomainFilter() } func (im *DynamoDBRegistry) OwnerID() string { return im.ownerID } // Records returns the current records from the registry. func (im *DynamoDBRegistry) 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 im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval { log.Debug("Using cached records.") return im.recordsCache, nil } if im.labels == nil { if err := im.readLabels(ctx); err != nil { return nil, err } } records, err := im.provider.Records(ctx) if err != nil { return nil, err } orphanedLabels := sets.KeySet(im.labels) endpoints := make([]*endpoint.Endpoint, 0, len(records)) labelMap := map[endpoint.EndpointKey]endpoint.Labels{} txtRecordsMap := map[endpoint.EndpointKey]*endpoint.Endpoint{} for _, record := range records { key := record.Key() if labels := im.labels[key]; labels != nil { record.Labels = labels orphanedLabels.Delete(key) } else { record.Labels = endpoint.NewLabels() if record.RecordType == endpoint.RecordTypeTXT { // We simply assume that TXT records for the TXT registry will always have only one target. if labels, err := endpoint.NewLabelsFromString(record.Targets[0], im.txtEncryptAESKey); err == nil { endpointName, recordType := im.mapper.ToEndpointName(record.DNSName) key := endpoint.EndpointKey{ DNSName: endpointName, SetIdentifier: record.SetIdentifier, } if recordType == endpoint.RecordTypeAAAA { key.RecordType = recordType } labelMap[key] = labels txtRecordsMap[key] = record continue } } } endpoints = append(endpoints, record) } im.orphanedLabels = orphanedLabels // Migrate label data from TXT registry. if len(labelMap) > 0 { for _, ep := range endpoints { if _, ok := im.labels[ep.Key()]; ok { continue } dnsNameSplit := strings.Split(ep.DNSName, ".") // If specified, replace a leading asterisk in the generated txt record name with some other string if im.wildcardReplacement != "" && dnsNameSplit[0] == "*" { dnsNameSplit[0] = im.wildcardReplacement } dnsName := strings.Join(dnsNameSplit, ".") key := endpoint.EndpointKey{ DNSName: dnsName, SetIdentifier: ep.SetIdentifier, } if ep.RecordType == endpoint.RecordTypeAAAA { key.RecordType = ep.RecordType } if labels, ok := labelMap[key]; ok { maps.Copy(ep.Labels, labels) ep.SetProviderSpecificProperty(dynamodbAttributeMigrate, "true") delete(txtRecordsMap, key) } } } // Remove any unused TXT ownership records owned by us if len(txtRecordsMap) > 0 && !plan.IsManagedRecord(endpoint.RecordTypeTXT, im.managedRecordTypes, im.excludeRecordTypes) { log.Infof("Old TXT ownership records will not be deleted because \"TXT\" is not in the set of managed record types.") } for _, record := range txtRecordsMap { record.Labels[endpoint.OwnerLabelKey] = im.ownerID endpoints = append(endpoints, record) } // Update the cache. if im.cacheInterval > 0 { im.recordsCache = endpoints im.recordsCacheRefreshTime = time.Now() } return endpoints, nil } // ApplyChanges updates the DNS provider and DynamoDB table with the changes. func (im *DynamoDBRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { filteredChanges := &plan.Changes{ Create: changes.Create, UpdateNew: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateNew), UpdateOld: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateOld), Delete: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.Delete), } statements := make([]dynamodbtypes.BatchStatementRequest, 0, len(filteredChanges.Create)+len(filteredChanges.UpdateNew)) for _, r := range filteredChanges.Create { if r.Labels == nil { r.Labels = make(map[string]string) } r.Labels[endpoint.OwnerLabelKey] = im.ownerID key := r.Key() oldLabels := im.labels[key] if oldLabels == nil { statements = im.appendInsert(statements, key, r.Labels) } else { im.orphanedLabels.Delete(key) statements = im.appendUpdate(statements, key, oldLabels, r.Labels) } im.labels[key] = r.Labels if im.cacheInterval > 0 { im.addToCache(r) } } for _, r := range filteredChanges.Delete { delete(im.labels, r.Key()) if im.cacheInterval > 0 { im.removeFromCache(r) } } oldLabels := make(map[endpoint.EndpointKey]endpoint.Labels, len(filteredChanges.UpdateOld)) needMigration := map[endpoint.EndpointKey]bool{} for _, r := range filteredChanges.UpdateOld { oldLabels[r.Key()] = r.Labels if _, ok := r.GetProviderSpecificProperty(dynamodbAttributeMigrate); ok { needMigration[r.Key()] = true } // remove old version of record from cache if im.cacheInterval > 0 { im.removeFromCache(r) } } for _, r := range filteredChanges.UpdateNew { key := r.Key() if needMigration[key] { statements = im.appendInsert(statements, key, r.Labels) // Invalidate the records cache so the next sync deletes the TXT ownership record im.recordsCache = nil } else { statements = im.appendUpdate(statements, key, oldLabels[key], r.Labels) } // add new version of record to caches im.labels[key] = r.Labels if im.cacheInterval > 0 { im.addToCache(r) } } err := im.executeStatements(ctx, statements, func(request dynamodbtypes.BatchStatementRequest, response dynamodbtypes.BatchStatementResponse) error { var context string if strings.HasPrefix(*request.Statement, "INSERT") { if response.Error.Code == dynamodbtypes.BatchStatementErrorCodeEnumDuplicateItem { // We lost a race with a different owner or another owner has an orphaned ownership record. key, err := fromDynamoKey(request.Parameters[0]) if err != nil { return err } for i, ep := range filteredChanges.Create { if ep.Key() == key { log.Infof("Skipping endpoint %v because owner does not match", ep) filteredChanges.Create = append(filteredChanges.Create[:i], filteredChanges.Create[i+1:]...) // The dynamodb insertion failed; remove from our cache. im.removeFromCache(ep) delete(im.labels, key) return nil } } } var record string if err := attributevalue.Unmarshal(request.Parameters[0], &record); err != nil { return fmt.Errorf("inserting dynamodb record: %w", err) } context = fmt.Sprintf("inserting dynamodb record %q", record) } else { var record string if err := attributevalue.Unmarshal(request.Parameters[1], &record); err != nil { return fmt.Errorf("inserting dynamodb record: %w", err) } context = fmt.Sprintf("updating dynamodb record %q", record) } return fmt.Errorf("%s: %s: %s", context, response.Error.Code, *response.Error.Message) }) if err != nil { im.recordsCache = nil im.labels = nil return err } // When caching is enabled, disable the provider from using the cache. if im.cacheInterval > 0 { ctx = context.WithValue(ctx, provider.RecordsContextKey, nil) } err = im.provider.ApplyChanges(ctx, filteredChanges) if err != nil { im.recordsCache = nil im.labels = nil return err } statements = make([]dynamodbtypes.BatchStatementRequest, 0, len(filteredChanges.Delete)+len(im.orphanedLabels)) for _, r := range filteredChanges.Delete { statements = im.appendDelete(statements, r.Key()) } for r := range im.orphanedLabels { statements = im.appendDelete(statements, r) delete(im.labels, r) } im.orphanedLabels = nil return im.executeStatements(ctx, statements, func(request dynamodbtypes.BatchStatementRequest, response dynamodbtypes.BatchStatementResponse) error { im.labels = nil record, err := fromDynamoKey(request.Parameters[0]) if err != nil { return fmt.Errorf("deleting dynamodb record: %w", err) } return fmt.Errorf("deleting dynamodb record %q: %s: %s", record, response.Error.Code, *response.Error.Message) }) } // AdjustEndpoints modifies the endpoints as needed by the specific provider. func (im *DynamoDBRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return im.provider.AdjustEndpoints(endpoints) } func (im *DynamoDBRegistry) readLabels(ctx context.Context) error { table, err := im.dynamodbAPI.DescribeTable(ctx, &awsdynamodb.DescribeTableInput{ TableName: aws.String(im.table), }) if err != nil { return fmt.Errorf("describing table %q: %w", im.table, err) } foundKey := false for _, def := range table.Table.AttributeDefinitions { if *def.AttributeName == "k" { if def.AttributeType != dynamodbtypes.ScalarAttributeTypeS { return fmt.Errorf("table %q attribute \"k\" must have type \"S\"", im.table) } foundKey = true } } if !foundKey { return fmt.Errorf("table %q must have attribute \"k\" of type \"S\"", im.table) } if *table.Table.KeySchema[0].AttributeName != "k" { return fmt.Errorf("table %q must have hash key \"k\"", im.table) } if len(table.Table.KeySchema) > 1 { return fmt.Errorf("table %q must not have a range key", im.table) } labels := map[endpoint.EndpointKey]endpoint.Labels{} scanPaginator := awsdynamodb.NewScanPaginator(im.dynamodbAPI, &awsdynamodb.ScanInput{ TableName: aws.String(im.table), FilterExpression: aws.String("o = :ownerval"), ExpressionAttributeValues: map[string]dynamodbtypes.AttributeValue{ ":ownerval": &dynamodbtypes.AttributeValueMemberS{Value: im.ownerID}, }, ProjectionExpression: aws.String("k,l"), ConsistentRead: aws.Bool(true), }) for scanPaginator.HasMorePages() { output, err := scanPaginator.NextPage(ctx) if err != nil { return fmt.Errorf("scanning table %q: %w", im.table, err) } for _, item := range output.Items { k, err := fromDynamoKey(item["k"]) if err != nil { return fmt.Errorf("querying dynamodb for key: %w", err) } l, err := fromDynamoLabels(item["l"], im.ownerID) if err != nil { return fmt.Errorf("querying dynamodb for labels: %w", err) } labels[k] = l } } im.labels = labels return nil } func fromDynamoKey(key dynamodbtypes.AttributeValue) (endpoint.EndpointKey, error) { var ep string if err := attributevalue.Unmarshal(key, &ep); err != nil { return endpoint.EndpointKey{}, fmt.Errorf("unmarshalling endpoint key: %w", err) } split := strings.SplitN(ep, "#", 3) return endpoint.EndpointKey{ DNSName: split[0], RecordType: split[1], SetIdentifier: split[2], }, nil } func toDynamoKey(key endpoint.EndpointKey) dynamodbtypes.AttributeValue { return &dynamodbtypes.AttributeValueMemberS{ Value: fmt.Sprintf("%s#%s#%s", key.DNSName, key.RecordType, key.SetIdentifier), } } func fromDynamoLabels(label dynamodbtypes.AttributeValue, owner string) (endpoint.Labels, error) { labels := endpoint.NewLabels() if err := attributevalue.Unmarshal(label, &labels); err != nil { return endpoint.Labels{}, fmt.Errorf("unmarshalling labels: %w", err) } labels[endpoint.OwnerLabelKey] = owner return labels, nil } func toDynamoLabels(labels endpoint.Labels) dynamodbtypes.AttributeValue { labelMap := make(map[string]dynamodbtypes.AttributeValue, len(labels)) for k, v := range labels { if k == endpoint.OwnerLabelKey { continue } labelMap[k] = &dynamodbtypes.AttributeValueMemberS{Value: v} } return &dynamodbtypes.AttributeValueMemberM{Value: labelMap} } func (im *DynamoDBRegistry) appendInsert(statements []dynamodbtypes.BatchStatementRequest, key endpoint.EndpointKey, newL endpoint.Labels) []dynamodbtypes.BatchStatementRequest { return append(statements, dynamodbtypes.BatchStatementRequest{ Statement: aws.String(fmt.Sprintf("INSERT INTO %q VALUE {'k':?, 'o':?, 'l':?}", im.table)), ConsistentRead: aws.Bool(true), Parameters: []dynamodbtypes.AttributeValue{ toDynamoKey(key), &dynamodbtypes.AttributeValueMemberS{ Value: im.ownerID, }, toDynamoLabels(newL), }, }) } func (im *DynamoDBRegistry) appendUpdate(statements []dynamodbtypes.BatchStatementRequest, key endpoint.EndpointKey, old endpoint.Labels, newE endpoint.Labels) []dynamodbtypes.BatchStatementRequest { if len(old) == len(newE) { equal := true for k, v := range old { if newV, exists := newE[k]; !exists || v != newV { equal = false break } } if equal { return statements } } return append(statements, dynamodbtypes.BatchStatementRequest{ Statement: aws.String(fmt.Sprintf("UPDATE %q SET \"l\"=? WHERE \"k\"=?", im.table)), Parameters: []dynamodbtypes.AttributeValue{ toDynamoLabels(newE), toDynamoKey(key), }, }) } func (im *DynamoDBRegistry) appendDelete(statements []dynamodbtypes.BatchStatementRequest, key endpoint.EndpointKey) []dynamodbtypes.BatchStatementRequest { return append(statements, dynamodbtypes.BatchStatementRequest{ Statement: aws.String(fmt.Sprintf("DELETE FROM %q WHERE \"k\"=? AND \"o\"=?", im.table)), Parameters: []dynamodbtypes.AttributeValue{ toDynamoKey(key), &dynamodbtypes.AttributeValueMemberS{Value: im.ownerID}, }, }) } func (im *DynamoDBRegistry) executeStatements(ctx context.Context, statements []dynamodbtypes.BatchStatementRequest, handleErr func(request dynamodbtypes.BatchStatementRequest, response dynamodbtypes.BatchStatementResponse) error) error { for len(statements) > 0 { var chunk []dynamodbtypes.BatchStatementRequest if len(statements) > int(dynamodbMaxBatchSize) { chunk = statements[:dynamodbMaxBatchSize] statements = statements[dynamodbMaxBatchSize:] } else { chunk = statements statements = nil } output, err := im.dynamodbAPI.BatchExecuteStatement(ctx, &awsdynamodb.BatchExecuteStatementInput{ Statements: chunk, }) if err != nil { return err } for i, response := range output.Responses { request := chunk[i] if response.Error == nil { op, _, _ := strings.Cut(*request.Statement, " ") var key string if op == "UPDATE" { if err := attributevalue.Unmarshal(request.Parameters[1], &key); err != nil { return err } } else { if err := attributevalue.Unmarshal(request.Parameters[0], &key); err != nil { return err } } log.Infof("%s dynamodb record %q", op, key) } else { if err := handleErr(request, response); err != nil { return err } } } } return nil } func (im *DynamoDBRegistry) addToCache(ep *endpoint.Endpoint) { if im.recordsCache != nil { im.recordsCache = append(im.recordsCache, ep) } } func (im *DynamoDBRegistry) removeFromCache(ep *endpoint.Endpoint) { if im.recordsCache == nil || ep == nil { return } for i, e := range im.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. im.recordsCache = append(im.recordsCache[:i], im.recordsCache[i+1:]...) return } } } func WithRegion(region string) func(*awsdynamodb.Options) { if region == "" { return nil } return func(opts *awsdynamodb.Options) { opts.Region = region } } ================================================ FILE: registry/dynamodb/registry_test.go ================================================ /* Copyright 2023 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 dynamodb import ( "context" "strings" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" "github.com/aws/aws-sdk-go-v2/service/dynamodb" dynamodbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/provider/inmemory" ) const ( testZone = "test-zone.example.org" ) func TestDynamoDBRegistryNew(t *testing.T) { api, p := newDynamoDBAPIStub(t, nil) _, err := newRegistry(p, "test-owner", api, "test-table", "", "", "", []string{}, []string{}, []byte(""), time.Hour) require.NoError(t, err) _, err = newRegistry(p, "test-owner", api, "test-table", "testPrefix", "", "", []string{}, []string{}, []byte(""), time.Hour) require.NoError(t, err) _, err = newRegistry(p, "test-owner", api, "test-table", "", "testSuffix", "", []string{}, []string{}, []byte(""), time.Hour) require.NoError(t, err) _, err = newRegistry(p, "test-owner", api, "test-table", "", "", "testWildcard", []string{}, []string{}, []byte(""), time.Hour) require.NoError(t, err) _, err = newRegistry(p, "test-owner", api, "test-table", "", "", "testWildcard", []string{}, []string{}, []byte(";k&l)nUC/33:{?d{3)54+,AD?]SX%yh^"), time.Hour) require.NoError(t, err) _, err = newRegistry(p, "", api, "test-table", "", "", "", []string{}, []string{}, []byte(""), time.Hour) require.EqualError(t, err, "owner id cannot be empty") _, err = newRegistry(p, "test-owner", api, "", "", "", "", []string{}, []string{}, []byte(""), time.Hour) require.EqualError(t, err, "table cannot be empty") _, err = newRegistry(p, "test-owner", api, "test-table", "", "", "", []string{}, []string{}, []byte(";k&l)nUC/33:{?d{3)54+,AD?]SX%yh^x"), time.Hour) require.EqualError(t, err, "the AES Encryption key must be 32 bytes long, in either plain text or base64-encoded format") _, err = newRegistry(p, "test-owner", api, "test-table", "testPrefix", "testSuffix", "", []string{}, []string{}, []byte(""), time.Hour) require.EqualError(t, err, "txt-prefix and txt-suffix are mutually exclusive") } func TestDynamoDBRegistryNew_EncryptionConfig(t *testing.T) { api, p := newDynamoDBAPIStub(t, nil) tests := []struct { encEnabled bool aesKeyRaw []byte aesKeySanitized []byte errorExpected bool }{ { encEnabled: true, aesKeyRaw: []byte("123456789012345678901234567890asdfasdfasdfasdfa12"), aesKeySanitized: []byte{}, errorExpected: true, }, { encEnabled: true, aesKeyRaw: []byte("passphrasewhichneedstobe32bytes!"), aesKeySanitized: []byte("passphrasewhichneedstobe32bytes!"), errorExpected: false, }, { encEnabled: true, aesKeyRaw: []byte("ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY="), aesKeySanitized: []byte{100, 248, 173, 47, 67, 70, 85, 0, 89, 109, 48, 250, 15, 5, 201, 204, 63, 17, 137, 43, 82, 107, 60, 216, 93, 11, 29, 82, 140, 11, 81, 22}, errorExpected: false, }, } for _, test := range tests { actual, err := newRegistry(p, "test-owner", api, "test-table", "", "", "", []string{}, []string{}, test.aesKeyRaw, time.Hour) if test.errorExpected { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, test.aesKeySanitized, actual.txtEncryptAESKey) } } } func TestDynamoDBRegistryRecordsBadTable(t *testing.T) { for _, tc := range []struct { name string setup func(desc *dynamodbtypes.TableDescription) expected string }{ { name: "missing attribute k", setup: func(desc *dynamodbtypes.TableDescription) { desc.AttributeDefinitions[0].AttributeName = aws.String("wrong") }, expected: "table \"test-table\" must have attribute \"k\" of type \"S\"", }, { name: "wrong attribute type", setup: func(desc *dynamodbtypes.TableDescription) { desc.AttributeDefinitions[0].AttributeType = "SS" }, expected: "table \"test-table\" attribute \"k\" must have type \"S\"", }, { name: "wrong key", setup: func(desc *dynamodbtypes.TableDescription) { desc.KeySchema[0].AttributeName = aws.String("wrong") }, expected: "table \"test-table\" must have hash key \"k\"", }, { name: "has range key", setup: func(desc *dynamodbtypes.TableDescription) { desc.AttributeDefinitions = append(desc.AttributeDefinitions, dynamodbtypes.AttributeDefinition{ AttributeName: aws.String("o"), AttributeType: dynamodbtypes.ScalarAttributeTypeS, }) desc.KeySchema = append(desc.KeySchema, dynamodbtypes.KeySchemaElement{ AttributeName: aws.String("o"), KeyType: dynamodbtypes.KeyTypeRange, }) }, expected: "table \"test-table\" must not have a range key", }, } { t.Run(tc.name, func(t *testing.T) { api, p := newDynamoDBAPIStub(t, nil) tc.setup(&api.tableDescription) r, _ := newRegistry(p, "test-owner", api, "test-table", "", "", "", []string{}, []string{}, nil, time.Hour) _, err := r.Records(t.Context()) assert.EqualError(t, err, tc.expected) }) } } func TestDynamoDBRegistryRecords(t *testing.T) { api, p := newDynamoDBAPIStub(t, nil) ctx := t.Context() expectedRecords := []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, { DNSName: "migrate.test-zone.example.org", Targets: endpoint.Targets{"3.3.3.3"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-3", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, ProviderSpecific: endpoint.ProviderSpecific{ { Name: dynamodbAttributeMigrate, Value: "true", }, }, }, { DNSName: "txt.orphaned.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\""}, RecordType: endpoint.RecordTypeTXT, SetIdentifier: "set-3", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", }, }, { DNSName: "txt.baz.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\""}, RecordType: endpoint.RecordTypeTXT, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", }, }, } r, _ := newRegistry(p, "test-owner", api, "test-table", "txt.", "", "", []string{}, []string{}, nil, time.Hour) _ = p.(*wrappedProvider).Provider.ApplyChanges(t.Context(), &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("migrate.test-zone.example.org", endpoint.RecordTypeA, "3.3.3.3").WithSetIdentifier("set-3"), endpoint.NewEndpoint("txt.migrate.test-zone.example.org", endpoint.RecordTypeTXT, "\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\"").WithSetIdentifier("set-3"), endpoint.NewEndpoint("txt.orphaned.test-zone.example.org", endpoint.RecordTypeTXT, "\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\"").WithSetIdentifier("set-3"), endpoint.NewEndpoint("txt.baz.test-zone.example.org", endpoint.RecordTypeTXT, "\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/other-ingress\"").WithSetIdentifier("set-2"), }, }) records, err := r.Records(ctx) require.NoError(t, err) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) } func TestDynamoDBRegistryApplyChanges(t *testing.T) { for _, tc := range []struct { name string maxBatchSize uint8 stubConfig DynamoDBStubConfig addRecords []*endpoint.Endpoint changes plan.Changes expectedError string expectedRecords []*endpoint.Endpoint }{ { name: "create", changes: plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "new.test-zone.example.org", Targets: endpoint.Targets{"new.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectInsert: map[string]map[string]string{ "new.test-zone.example.org#CNAME#set-new": {endpoint.ResourceLabelKey: "ingress/default/new-ingress"}, }, ExpectDelete: sets.New("quux.test-zone.example.org#A#set-2"), }, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, { DNSName: "new.test-zone.example.org", Targets: endpoint.Targets{"new.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, { name: "create more entries than DynamoDB batch size limit", maxBatchSize: 2, changes: plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "new1.test-zone.example.org", Targets: endpoint.Targets{"new1.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/new1-ingress", }, }, { DNSName: "new2.test-zone.example.org", Targets: endpoint.Targets{"new2.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/new2-ingress", }, }, { DNSName: "new3.test-zone.example.org", Targets: endpoint.Targets{"new3.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/new3-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectInsert: map[string]map[string]string{ "new1.test-zone.example.org#CNAME#set-new": {endpoint.ResourceLabelKey: "ingress/default/new1-ingress"}, "new2.test-zone.example.org#CNAME#set-new": {endpoint.ResourceLabelKey: "ingress/default/new2-ingress"}, "new3.test-zone.example.org#CNAME#set-new": {endpoint.ResourceLabelKey: "ingress/default/new3-ingress"}, }, ExpectDelete: sets.New("quux.test-zone.example.org#A#set-2"), }, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, { DNSName: "new1.test-zone.example.org", Targets: endpoint.Targets{"new1.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new1-ingress", }, }, { DNSName: "new2.test-zone.example.org", Targets: endpoint.Targets{"new2.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new2-ingress", }, }, { DNSName: "new3.test-zone.example.org", Targets: endpoint.Targets{"new3.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new3-ingress", }, }, }, }, { name: "create orphaned", changes: plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "quux.test-zone.example.org", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/quux-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{}, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, { DNSName: "quux.test-zone.example.org", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/quux-ingress", }, }, }, }, { name: "create orphaned change", changes: plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "quux.test-zone.example.org", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectUpdate: map[string]map[string]string{ "quux.test-zone.example.org#A#set-2": {endpoint.ResourceLabelKey: "ingress/default/new-ingress"}, }, }, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, { DNSName: "quux.test-zone.example.org", Targets: endpoint.Targets{"5.5.5.5"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, { name: "create duplicate", changes: plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "new.test-zone.example.org", Targets: endpoint.Targets{"new.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectInsertError: map[string]dynamodbtypes.BatchStatementErrorCodeEnum{ "new.test-zone.example.org#CNAME#set-new": dynamodbtypes.BatchStatementErrorCodeEnumDuplicateItem, }, ExpectDelete: sets.New("quux.test-zone.example.org#A#set-2"), }, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, }, }, { name: "create error", changes: plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "new.test-zone.example.org", Targets: endpoint.Targets{"new.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "set-new", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectInsertError: map[string]dynamodbtypes.BatchStatementErrorCodeEnum{ "new.test-zone.example.org#CNAME#set-new": "TestingError", }, }, expectedError: "inserting dynamodb record \"new.test-zone.example.org#CNAME#set-new\": TestingError: testing error", expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, }, }, { name: "update", changes: plan.Changes{ UpdateOld: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"new-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectDelete: sets.New("quux.test-zone.example.org#A#set-2"), }, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"new-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, }, }, { name: "update change", changes: plan.Changes{ UpdateOld: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"new-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectDelete: sets.New("quux.test-zone.example.org#A#set-2"), ExpectUpdate: map[string]map[string]string{ "bar.test-zone.example.org#CNAME#": {endpoint.ResourceLabelKey: "ingress/default/new-ingress"}, }, }, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"new-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, }, }, { name: "update migrate", addRecords: []*endpoint.Endpoint{ { DNSName: "txt.bar.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/new-ingress\""}, RecordType: endpoint.RecordTypeTXT, SetIdentifier: "set-1", }, }, changes: plan.Changes{ UpdateOld: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, ProviderSpecific: endpoint.ProviderSpecific{ { Name: dynamodbAttributeMigrate, Value: "true", }, }, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectDelete: sets.New("quux.test-zone.example.org#A#set-2"), ExpectInsert: map[string]map[string]string{ "bar.test-zone.example.org#CNAME#": {endpoint.ResourceLabelKey: "ingress/default/new-ingress"}, }, }, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, { DNSName: "txt.bar.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=test-owner,external-dns/resource=ingress/default/new-ingress\""}, RecordType: endpoint.RecordTypeTXT, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", }, }, }, }, { name: "update error", changes: plan.Changes{ UpdateOld: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"new-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/new-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectUpdateError: map[string]dynamodbtypes.BatchStatementErrorCodeEnum{ "bar.test-zone.example.org#CNAME#": "TestingError", }, }, expectedError: "updating dynamodb record \"bar.test-zone.example.org#CNAME#\": TestingError: testing error", expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, }, }, { name: "delete", changes: plan.Changes{ Delete: []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, }, }, stubConfig: DynamoDBStubConfig{ ExpectDelete: sets.New("bar.test-zone.example.org#CNAME#", "quux.test-zone.example.org#A#set-2"), }, expectedRecords: []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-1", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "baz.test-zone.example.org", Targets: endpoint.Targets{"2.2.2.2"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "set-2", Labels: map[string]string{ endpoint.OwnerLabelKey: "test-owner", endpoint.ResourceLabelKey: "ingress/default/other-ingress", }, }, }, }, } { t.Run(tc.name, func(t *testing.T) { originalMaxBatchSize := dynamodbMaxBatchSize if tc.maxBatchSize > 0 { dynamodbMaxBatchSize = tc.maxBatchSize } api, p := newDynamoDBAPIStub(t, &tc.stubConfig) if len(tc.addRecords) > 0 { _ = p.(*wrappedProvider).Provider.ApplyChanges(t.Context(), &plan.Changes{ Create: tc.addRecords, }) } ctx := t.Context() r, _ := newRegistry(p, "test-owner", api, "test-table", "txt.", "", "", []string{}, []string{}, nil, time.Hour) _, err := r.Records(ctx) require.NoError(t, err) err = r.ApplyChanges(ctx, &tc.changes) if tc.expectedError == "" { assert.NoError(t, err) } else { assert.EqualError(t, err, tc.expectedError) } assert.Empty(t, tc.stubConfig.ExpectInsert, "all expected inserts made") assert.Empty(t, tc.stubConfig.ExpectDelete, "all expected deletions made") records, err := r.Records(ctx) require.NoError(t, err) assert.True(t, testutils.SameEndpoints(records, tc.expectedRecords)) r.recordsCache = nil records, err = r.Records(ctx) require.NoError(t, err) assert.True(t, testutils.SameEndpoints(records, tc.expectedRecords)) if tc.expectedError == "" { assert.Empty(t, r.orphanedLabels) } dynamodbMaxBatchSize = originalMaxBatchSize }) } } // DynamoDBAPIStub is a minimal implementation of DynamoDBAPI, used primarily for unit testing. type DynamoDBStub struct { t *testing.T stubConfig *DynamoDBStubConfig tableDescription dynamodbtypes.TableDescription changesApplied bool } type DynamoDBStubConfig struct { ExpectInsert map[string]map[string]string ExpectInsertError map[string]dynamodbtypes.BatchStatementErrorCodeEnum ExpectUpdate map[string]map[string]string ExpectUpdateError map[string]dynamodbtypes.BatchStatementErrorCodeEnum ExpectDelete sets.Set[string] } type wrappedProvider struct { provider.Provider stub *DynamoDBStub } func (w *wrappedProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { assert.False(w.stub.t, w.stub.changesApplied, "ApplyChanges already called") w.stub.changesApplied = true return w.Provider.ApplyChanges(ctx, changes) } func newDynamoDBAPIStub(t *testing.T, stubConfig *DynamoDBStubConfig) (*DynamoDBStub, provider.Provider) { stub := &DynamoDBStub{ t: t, stubConfig: stubConfig, tableDescription: dynamodbtypes.TableDescription{ AttributeDefinitions: []dynamodbtypes.AttributeDefinition{ { AttributeName: aws.String("k"), AttributeType: dynamodbtypes.ScalarAttributeTypeS, }, }, KeySchema: []dynamodbtypes.KeySchemaElement{ { AttributeName: aws.String("k"), KeyType: dynamodbtypes.KeyTypeHash, }, }, }, } p := inmemory.NewInMemoryProvider() _ = p.CreateZone(testZone) _ = p.ApplyChanges(t.Context(), &plan.Changes{ Create: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo.test-zone.example.org", endpoint.RecordTypeCNAME, "foo.loadbalancer.com"), endpoint.NewEndpoint("bar.test-zone.example.org", endpoint.RecordTypeCNAME, "my-domain.com"), endpoint.NewEndpoint("baz.test-zone.example.org", endpoint.RecordTypeA, "1.1.1.1").WithSetIdentifier("set-1"), endpoint.NewEndpoint("baz.test-zone.example.org", endpoint.RecordTypeA, "2.2.2.2").WithSetIdentifier("set-2"), }, }) return stub, &wrappedProvider{ Provider: p, stub: stub, } } func (r *DynamoDBStub) DescribeTable(ctx context.Context, input *dynamodb.DescribeTableInput, _ ...func(*dynamodb.Options)) (*dynamodb.DescribeTableOutput, error) { assert.NotNil(r.t, ctx) assert.Equal(r.t, "test-table", *input.TableName, "table name") return &dynamodb.DescribeTableOutput{ Table: &r.tableDescription, }, nil } func (r *DynamoDBStub) Scan(ctx context.Context, input *dynamodb.ScanInput, _ ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error) { assert.NotNil(r.t, ctx) assert.Equal(r.t, "test-table", *input.TableName, "table name") assert.Equal(r.t, "o = :ownerval", *input.FilterExpression) assert.Len(r.t, input.ExpressionAttributeValues, 1) var owner string assert.NoError(r.t, attributevalue.Unmarshal(input.ExpressionAttributeValues[":ownerval"], &owner)) assert.Equal(r.t, "test-owner", owner) assert.Equal(r.t, "k,l", *input.ProjectionExpression) assert.True(r.t, *input.ConsistentRead) return &dynamodb.ScanOutput{ Items: []map[string]dynamodbtypes.AttributeValue{ { "k": &dynamodbtypes.AttributeValueMemberS{Value: "bar.test-zone.example.org#CNAME#"}, "l": &dynamodbtypes.AttributeValueMemberM{Value: map[string]dynamodbtypes.AttributeValue{ endpoint.ResourceLabelKey: &dynamodbtypes.AttributeValueMemberS{Value: "ingress/default/my-ingress"}, }}, }, { "k": &dynamodbtypes.AttributeValueMemberS{Value: "baz.test-zone.example.org#A#set-1"}, "l": &dynamodbtypes.AttributeValueMemberM{Value: map[string]dynamodbtypes.AttributeValue{ endpoint.ResourceLabelKey: &dynamodbtypes.AttributeValueMemberS{Value: "ingress/default/my-ingress"}, }}, }, { "k": &dynamodbtypes.AttributeValueMemberS{Value: "baz.test-zone.example.org#A#set-2"}, "l": &dynamodbtypes.AttributeValueMemberM{Value: map[string]dynamodbtypes.AttributeValue{ endpoint.ResourceLabelKey: &dynamodbtypes.AttributeValueMemberS{Value: "ingress/default/other-ingress"}, }}, }, { "k": &dynamodbtypes.AttributeValueMemberS{Value: "quux.test-zone.example.org#A#set-2"}, "l": &dynamodbtypes.AttributeValueMemberM{Value: map[string]dynamodbtypes.AttributeValue{ endpoint.ResourceLabelKey: &dynamodbtypes.AttributeValueMemberS{Value: "ingress/default/quux-ingress"}, }}, }, }, }, nil } func (r *DynamoDBStub) BatchExecuteStatement(context context.Context, input *dynamodb.BatchExecuteStatementInput, _ ...func(*dynamodb.Options)) (*dynamodb.BatchExecuteStatementOutput, error) { assert.NotNil(r.t, context) hasDelete := strings.HasPrefix(strings.ToLower(*input.Statements[0].Statement), "delete") assert.Equal(r.t, hasDelete, r.changesApplied, "delete after provider changes, everything else before") assert.LessOrEqual(r.t, len(input.Statements), 25) responses := make([]dynamodbtypes.BatchStatementResponse, 0, len(input.Statements)) for _, statement := range input.Statements { assert.Equal(r.t, hasDelete, strings.HasPrefix(strings.ToLower(*statement.Statement), "delete")) switch *statement.Statement { case "DELETE FROM \"test-table\" WHERE \"k\"=? AND \"o\"=?": assert.True(r.t, r.changesApplied, "unexpected delete before provider changes") var key string require.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[0], &key)) assert.True(r.t, r.stubConfig.ExpectDelete.Has(key), "unexpected delete for key %q", key) r.stubConfig.ExpectDelete.Delete(key) var testOwner string assert.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[1], &testOwner)) assert.Equal(r.t, "test-owner", testOwner) responses = append(responses, dynamodbtypes.BatchStatementResponse{}) case "INSERT INTO \"test-table\" VALUE {'k':?, 'o':?, 'l':?}": assert.False(r.t, r.changesApplied, "unexpected insert after provider changes") var key string assert.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[0], &key)) if code, ok := r.stubConfig.ExpectInsertError[key]; ok { delete(r.stubConfig.ExpectInsertError, key) responses = append(responses, dynamodbtypes.BatchStatementResponse{ Error: &dynamodbtypes.BatchStatementError{ Code: code, Message: aws.String("testing error"), }, }) break } expectedLabels, found := r.stubConfig.ExpectInsert[key] assert.True(r.t, found, "unexpected insert for key %q", key) delete(r.stubConfig.ExpectInsert, key) var testOwner string require.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[1], &testOwner)) assert.Equal(r.t, "test-owner", testOwner) var labels map[string]string err := attributevalue.Unmarshal(statement.Parameters[2], &labels) assert.NoError(r.t, err) for label, value := range labels { expectedValue, found := expectedLabels[label] assert.True(r.t, found, "insert for key %q has unexpected label %q", key, label) delete(expectedLabels, label) assert.Equal(r.t, expectedValue, value, "insert for key %q label %q value", key, label) } for label := range expectedLabels { r.t.Errorf("insert for key %q did not get expected label %q", key, label) } responses = append(responses, dynamodbtypes.BatchStatementResponse{}) case "UPDATE \"test-table\" SET \"l\"=? WHERE \"k\"=?": assert.False(r.t, r.changesApplied, "unexpected update after provider changes") var key string assert.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[1], &key)) if code, exists := r.stubConfig.ExpectUpdateError[key]; exists { delete(r.stubConfig.ExpectInsertError, key) responses = append(responses, dynamodbtypes.BatchStatementResponse{ Error: &dynamodbtypes.BatchStatementError{ Code: code, Message: aws.String("testing error"), }, }) break } expectedLabels, found := r.stubConfig.ExpectUpdate[key] assert.True(r.t, found, "unexpected update for key %q", key) delete(r.stubConfig.ExpectUpdate, key) var labels map[string]string assert.NoError(r.t, attributevalue.Unmarshal(statement.Parameters[0], &labels)) for label, value := range labels { expectedValue, found := expectedLabels[label] assert.True(r.t, found, "update for key %q has unexpected label %q", key, label) delete(expectedLabels, label) assert.Equal(r.t, expectedValue, value, "update for key %q label %q value", key, label) } for label := range expectedLabels { r.t.Errorf("update for key %q did not get expected label %q", key, label) } responses = append(responses, dynamodbtypes.BatchStatementResponse{}) default: r.t.Errorf("unexpected statement: %s", *statement.Statement) } } return &dynamodb.BatchExecuteStatementOutput{ Responses: responses, }, nil } ================================================ FILE: registry/factory/registry.go ================================================ /* 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 factory import ( "fmt" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/registry" "sigs.k8s.io/external-dns/registry/awssd" "sigs.k8s.io/external-dns/registry/dynamodb" "sigs.k8s.io/external-dns/registry/noop" "sigs.k8s.io/external-dns/registry/txt" ) // RegistryConstructor is a function that creates a Registry from configuration and a provider. type RegistryConstructor func(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) // Select creates a registry based on the given configuration. func Select(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) { constructor, ok := registries(cfg.Registry) if !ok { return nil, fmt.Errorf("unknown registry: %s", cfg.Registry) } return constructor(cfg, p) } // registries looks up the constructor for the named registry. func registries(selector string) (RegistryConstructor, bool) { m := map[string]RegistryConstructor{ externaldns.RegistryDynamoDB: dynamodb.New, externaldns.RegistryNoop: noop.New, externaldns.RegistryTXT: txt.New, externaldns.RegistryAWSSD: awssd.New, } c, ok := m[selector] return c, ok } ================================================ FILE: registry/factory/registry_test.go ================================================ /* 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 factory import ( "reflect" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/provider" fakeprovider "sigs.k8s.io/external-dns/provider/fakes" "sigs.k8s.io/external-dns/provider/inmemory" "sigs.k8s.io/external-dns/registry" "sigs.k8s.io/external-dns/registry/awssd" "sigs.k8s.io/external-dns/registry/dynamodb" "sigs.k8s.io/external-dns/registry/noop" "sigs.k8s.io/external-dns/registry/txt" ) var ( _ registry.Registry = &awssd.AWSSDRegistry{} _ registry.Registry = &dynamodb.DynamoDBRegistry{} _ registry.Registry = &noop.NoopRegistry{} _ registry.Registry = &txt.TXTRegistry{} ) func TestSelectRegistry(t *testing.T) { tests := []struct { name string cfg *externaldns.Config provider provider.Provider wantErr bool wantType string }{ { name: "dynamoDB registry", cfg: &externaldns.Config{ Registry: externaldns.RegistryDynamoDB, AWSDynamoDBRegion: "us-west-2", AWSDynamoDBTable: "test-table", TXTOwnerID: "owner-id", TXTWildcardReplacement: "wildcard", ManagedDNSRecordTypes: []string{"A", "CNAME"}, ExcludeDNSRecordTypes: []string{"TXT"}, TXTCacheInterval: 60, }, provider: &fakeprovider.MockProvider{}, wantErr: false, wantType: "DynamoDBRegistry", }, { name: "noop registry", cfg: &externaldns.Config{ Registry: externaldns.RegistryNoop, }, provider: &fakeprovider.MockProvider{}, wantErr: false, wantType: "NoopRegistry", }, { name: "TXT registry", cfg: &externaldns.Config{ Registry: externaldns.RegistryTXT, TXTPrefix: "prefix", TXTOwnerID: "owner-id", TXTCacheInterval: 60, TXTWildcardReplacement: "wildcard", ManagedDNSRecordTypes: []string{"A", "CNAME"}, ExcludeDNSRecordTypes: []string{"TXT"}, }, provider: &fakeprovider.MockProvider{}, wantErr: false, wantType: "TXTRegistry", }, { name: "aws-sd registry", cfg: &externaldns.Config{ Registry: externaldns.RegistryAWSSD, TXTOwnerID: "owner-id", }, provider: &fakeprovider.MockProvider{}, wantErr: false, wantType: "AWSSDRegistry", }, { name: "unknown registry", cfg: &externaldns.Config{ Registry: "unknown", }, provider: &fakeprovider.MockProvider{}, wantErr: true, wantType: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reg, err := Select(tt.cfg, tt.provider) if tt.wantErr { require.Nil(t, reg) require.Error(t, err) } else { assert.NotNil(t, reg) require.NoError(t, err) assert.Contains(t, reflect.TypeOf(reg).String(), tt.wantType) } }) } } func TestSelectRegistryUnknown(t *testing.T) { cfg := externaldns.NewConfig() cfg.Registry = "nope" reg, err := Select(cfg, inmemory.NewInMemoryProvider()) require.Error(t, err) require.Nil(t, reg) } ================================================ FILE: registry/mapper/mapper.go ================================================ /* 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 mapper import ( "strings" "sigs.k8s.io/external-dns/endpoint" ) const ( recordTemplate = "%{record_type}" ) var ( supportedRecords = []string{ endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS, endpoint.RecordTypeMX, endpoint.RecordTypePTR, endpoint.RecordTypeSRV, endpoint.RecordTypeNAPTR, } ) // NameMapper is the interface for mapping between the endpoint for the source // and the endpoint for the TXT record. type NameMapper interface { ToEndpointName(string) (string, string) ToTXTName(string, string) string } // AffixNameMapper is a name mapper based on prefix/suffix affixes. type AffixNameMapper struct { prefix string suffix string wildcardReplacement string } // NewAffixNameMapper returns a new AffixNameMapper. func NewAffixNameMapper(prefix, suffix, wildcardReplacement string) AffixNameMapper { return AffixNameMapper{ prefix: strings.ToLower(prefix), suffix: strings.ToLower(suffix), wildcardReplacement: strings.ToLower(wildcardReplacement), } } func (a AffixNameMapper) ToEndpointName(dns string) (string, string) { lowerDNSName := strings.ToLower(dns) // drop prefix if a.isPrefix() { return a.dropAffixExtractType(lowerDNSName) } // drop suffix if a.isSuffix() { dc := strings.Count(a.suffix, ".") DNSName := strings.SplitN(lowerDNSName, ".", 2+dc) domainWithSuffix := strings.Join(DNSName[:1+dc], ".") r, rType := a.dropAffixExtractType(domainWithSuffix) if !strings.Contains(lowerDNSName, ".") { return r, rType } return r + "." + DNSName[1+dc], rType } return "", "" } func (a AffixNameMapper) ToTXTName(dns, recordType string) string { DNSName := strings.SplitN(dns, ".", 2) recordType = strings.ToLower(recordType) recordT := recordType + "-" prefix := a.normalizeAffixTemplate(a.prefix, recordType) suffix := a.normalizeAffixTemplate(a.suffix, recordType) // If specified, replace a leading asterisk in the generated txt record name with some other string if a.wildcardReplacement != "" && DNSName[0] == "*" { DNSName[0] = a.wildcardReplacement } if !a.recordTypeInAffix() { DNSName[0] = recordT + DNSName[0] } if len(DNSName) < 2 { return prefix + DNSName[0] + suffix } return prefix + DNSName[0] + suffix + "." + DNSName[1] } func (a AffixNameMapper) recordTypeInAffix() bool { if strings.Contains(a.prefix, recordTemplate) { return true } if strings.Contains(a.suffix, recordTemplate) { return true } return false } func (a AffixNameMapper) normalizeAffixTemplate(afix, recordType string) string { if strings.Contains(afix, recordTemplate) { return strings.ReplaceAll(afix, recordTemplate, recordType) } return afix } func (a AffixNameMapper) isPrefix() bool { return len(a.suffix) == 0 } func (a AffixNameMapper) isSuffix() bool { return len(a.prefix) == 0 && len(a.suffix) > 0 } func (a AffixNameMapper) dropAffixTemplate(name string) string { return strings.ReplaceAll(name, recordTemplate, "") } // dropAffixExtractType strips TXT record to find an endpoint name it manages. // It also returns the record type. func (a AffixNameMapper) dropAffixExtractType(name string) (string, string) { prefix := a.prefix suffix := a.suffix if a.recordTypeInAffix() { for _, t := range supportedRecords { tLower := strings.ToLower(t) iPrefix := strings.ReplaceAll(prefix, recordTemplate, tLower) iSuffix := strings.ReplaceAll(suffix, recordTemplate, tLower) if a.isPrefix() && strings.HasPrefix(name, iPrefix) { return strings.TrimPrefix(name, iPrefix), t } if a.isSuffix() && strings.HasSuffix(name, iSuffix) { return strings.TrimSuffix(name, iSuffix), t } } // handle old TXT records prefix = a.dropAffixTemplate(prefix) suffix = a.dropAffixTemplate(suffix) } if a.isPrefix() && strings.HasPrefix(name, prefix) { return extractRecordTypeDefaultPosition(strings.TrimPrefix(name, prefix)) } if a.isSuffix() && strings.HasSuffix(name, suffix) { return extractRecordTypeDefaultPosition(strings.TrimSuffix(name, suffix)) } return "", "" } // extractRecordTypeDefaultPosition extracts record type from the default position // when not using '%{record_type}' in the prefix/suffix func extractRecordTypeDefaultPosition(name string) (string, string) { nameS := strings.Split(name, "-") for _, t := range supportedRecords { if nameS[0] == strings.ToLower(t) { return strings.TrimPrefix(name, nameS[0]+"-"), t } } return name, "" } ================================================ FILE: registry/mapper/mapper_test.go ================================================ /* 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 mapper import ( "strings" "testing" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" ) var ( _ NameMapper = AffixNameMapper{} ) func TestAffixNameMapper_ToEndpointName(t *testing.T) { tests := []struct { name string mapper AffixNameMapper input string wantEndpointName string wantRecordType string }{ { name: "prefix with A record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), input: "a-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeA, }, { name: "prefix with AAAA record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), input: "aaaa-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeAAAA, }, { name: "prefix with CNAME record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), input: "cname-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeCNAME, }, { name: "prefix with NS record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), input: "ns-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeNS, }, { name: "prefix with MX record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), input: "mx-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeMX, }, { name: "prefix with SRV record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), input: "srv-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeSRV, }, { name: "prefix with PTR record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), input: "ptr-2.49.168.192.in-addr.arpa", wantEndpointName: "2.49.168.192.in-addr.arpa", wantRecordType: endpoint.RecordTypePTR, }, { name: "prefix with NAPTR record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), input: "naptr-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeNAPTR, }, { name: "suffix with A record type in affix", mapper: NewAffixNameMapper("", "-%{record_type}", ""), input: "foo-a.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeA, }, { name: "suffix with CNAME record type in affix", mapper: NewAffixNameMapper("", "-%{record_type}", ""), input: "foo-cname.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeCNAME, }, { name: "no affix with A record", mapper: NewAffixNameMapper("", "", ""), input: "a-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeA, }, { name: "no affix with AAAA record", mapper: NewAffixNameMapper("", "", ""), input: "aaaa-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeAAAA, }, { name: "no affix with CNAME record", mapper: NewAffixNameMapper("", "", ""), input: "cname-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeCNAME, }, { name: "no affix with NS record", mapper: NewAffixNameMapper("", "", ""), input: "ns-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeNS, }, { name: "no affix with MX record", mapper: NewAffixNameMapper("", "", ""), input: "mx-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeMX, }, { name: "no affix with SRV record", mapper: NewAffixNameMapper("", "", ""), input: "srv-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeSRV, }, { name: "default prefix with PTR record", mapper: NewAffixNameMapper("", "", ""), input: "ptr-2.49.168.192.in-addr.arpa", wantEndpointName: "2.49.168.192.in-addr.arpa", wantRecordType: endpoint.RecordTypePTR, }, { name: "no affix with NAPTR record", mapper: NewAffixNameMapper("", "", ""), input: "naptr-foo.example.com", wantEndpointName: "foo.example.com", wantRecordType: endpoint.RecordTypeNAPTR, }, { name: "suffix with txt record", mapper: NewAffixNameMapper("", "", ""), input: "txt-foo.example.com", wantEndpointName: "txt-foo.example.com", wantRecordType: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotName, gotType := tt.mapper.ToEndpointName(tt.input) assert.Equal(t, tt.wantEndpointName, gotName) assert.Equal(t, tt.wantRecordType, gotType) }) } // Verify all supported records are tested testedRecords := make(map[string]bool) for _, tt := range tests { if tt.wantRecordType != "" { testedRecords[tt.wantRecordType] = true } } for _, recordType := range supportedRecords { assert.True(t, testedRecords[recordType], "Record type %s is in supportedRecords but not tested in TestAffixNameMapper_ToEndpointName", recordType) } } func TestAffixNameMapper_ToTXTName(t *testing.T) { tests := []struct { name string mapper AffixNameMapper dns string recordType string wantTXTName string }{ { name: "prefix with A record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeA, wantTXTName: "a-foo.example.com", }, { name: "prefix with AAAA record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeAAAA, wantTXTName: "aaaa-foo.example.com", }, { name: "prefix with CNAME record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeCNAME, wantTXTName: "cname-foo.example.com", }, { name: "prefix with NS record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeNS, wantTXTName: "ns-foo.example.com", }, { name: "prefix with MX record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeMX, wantTXTName: "mx-foo.example.com", }, { name: "prefix with SRV record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeSRV, wantTXTName: "srv-foo.example.com", }, { name: "prefix with PTR record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), dns: "2.49.168.192.in-addr.arpa", recordType: endpoint.RecordTypePTR, wantTXTName: "ptr-2.49.168.192.in-addr.arpa", }, { name: "prefix with NAPTR record type in affix", mapper: NewAffixNameMapper("%{record_type}-", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeNAPTR, wantTXTName: "naptr-foo.example.com", }, { name: "suffix with A record type in affix", mapper: NewAffixNameMapper("", "-%{record_type}", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeA, wantTXTName: "foo-a.example.com", }, { name: "suffix with CNAME record type in affix", mapper: NewAffixNameMapper("", "-%{record_type}", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeCNAME, wantTXTName: "foo-cname.example.com", }, { name: "wildcard replacement with A record", mapper: NewAffixNameMapper("txt-", "", "wild"), dns: "*.example.com", recordType: endpoint.RecordTypeA, wantTXTName: "txt-a-wild.example.com", }, { name: "wildcard replacement with MX record", mapper: NewAffixNameMapper("txt-", "", "wild"), dns: "*.example.com", recordType: endpoint.RecordTypeMX, wantTXTName: "txt-mx-wild.example.com", }, { name: "no affix with A record", mapper: NewAffixNameMapper("", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeA, wantTXTName: "a-foo.example.com", }, { name: "no affix with AAAA record", mapper: NewAffixNameMapper("", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeAAAA, wantTXTName: "aaaa-foo.example.com", }, { name: "no affix with CNAME record", mapper: NewAffixNameMapper("", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeCNAME, wantTXTName: "cname-foo.example.com", }, { name: "no affix with NS record", mapper: NewAffixNameMapper("", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeNS, wantTXTName: "ns-foo.example.com", }, { name: "no affix with MX record", mapper: NewAffixNameMapper("", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeMX, wantTXTName: "mx-foo.example.com", }, { name: "no affix with SRV record", mapper: NewAffixNameMapper("", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeSRV, wantTXTName: "srv-foo.example.com", }, { name: "default prefix with PTR record", mapper: NewAffixNameMapper("", "", ""), dns: "2.49.168.192.in-addr.arpa", recordType: endpoint.RecordTypePTR, wantTXTName: "ptr-2.49.168.192.in-addr.arpa", }, { name: "no affix with NAPTR record", mapper: NewAffixNameMapper("", "", ""), dns: "foo.example.com", recordType: endpoint.RecordTypeNAPTR, wantTXTName: "naptr-foo.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.mapper.ToTXTName(tt.dns, tt.recordType) assert.Equal(t, tt.wantTXTName, got) }) } // Verify all supported records are tested testedRecords := make(map[string]bool) for _, tt := range tests { testedRecords[tt.recordType] = true } for _, recordType := range supportedRecords { assert.True(t, testedRecords[recordType], "Record type %s is in supportedRecords but not tested in TestAffixNameMapper_ToTXTName", recordType) } } func TestAffixNameMapper_RecordTypeInAffix(t *testing.T) { tests := []struct { name string mapper AffixNameMapper want bool }{ { name: "prefix contains record type", mapper: NewAffixNameMapper("%{record_type}-", "", ""), want: true, }, { name: "suffix contains record type", mapper: NewAffixNameMapper("", "-%{record_type}", ""), want: true, }, { name: "no record type in affix", mapper: NewAffixNameMapper("txt-", "-txt", ""), want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tt.mapper.recordTypeInAffix() assert.Equal(t, tt.want, got) }) } } func TestToEndpointNameNewTXT(t *testing.T) { tests := []struct { name string mapper NameMapper domain string txtDomain string recordType string }{ { name: "prefix", mapper: NewAffixNameMapper("foo", "", ""), domain: "example.com", recordType: "A", txtDomain: "fooa-example.com", }, { name: "suffix", mapper: NewAffixNameMapper("", "foo", ""), domain: "example", recordType: "AAAA", txtDomain: "aaaa-examplefoo", }, { name: "suffix", mapper: NewAffixNameMapper("", "foo", ""), domain: "example.com", recordType: "AAAA", txtDomain: "aaaa-examplefoo.com", }, { name: "prefix with dash", mapper: NewAffixNameMapper("foo-", "", ""), domain: "example.com", recordType: "A", txtDomain: "foo-a-example.com", }, { name: "suffix with dash", mapper: NewAffixNameMapper("", "-foo", ""), domain: "example.com", recordType: "CNAME", txtDomain: "cname-example-foo.com", }, { name: "prefix with dot", mapper: NewAffixNameMapper("foo.", "", ""), domain: "example.com", recordType: "CNAME", txtDomain: "foo.cname-example.com", }, { name: "suffix with dot", mapper: NewAffixNameMapper("", ".foo", ""), domain: "example.com", recordType: "CNAME", txtDomain: "cname-example.foo.com", }, { name: "prefix with multiple dots", mapper: NewAffixNameMapper("foo.bar.", "", ""), domain: "example.com", recordType: "CNAME", txtDomain: "foo.bar.cname-example.com", }, { name: "suffix with multiple dots", mapper: NewAffixNameMapper("", ".foo.bar.test", ""), domain: "example.com", recordType: "CNAME", txtDomain: "cname-example.foo.bar.test.com", }, { name: "templated prefix", mapper: NewAffixNameMapper("%{record_type}-foo", "", ""), domain: "example.com", recordType: "A", txtDomain: "a-fooexample.com", }, { name: "templated suffix", mapper: NewAffixNameMapper("", "foo-%{record_type}", ""), domain: "example.com", recordType: "A", txtDomain: "examplefoo-a.com", }, { name: "templated prefix with dot", mapper: NewAffixNameMapper("%{record_type}foo.", "", ""), domain: "example.com", recordType: "CNAME", txtDomain: "cnamefoo.example.com", }, { name: "templated suffix with dot", mapper: NewAffixNameMapper("", ".foo%{record_type}", ""), domain: "example.com", recordType: "A", txtDomain: "example.fooa.com", }, { name: "templated prefix with multiple dots", mapper: NewAffixNameMapper("bar.%{record_type}.foo.", "", ""), domain: "example.com", recordType: "CNAME", txtDomain: "bar.cname.foo.example.com", }, { name: "templated suffix with multiple dots", mapper: NewAffixNameMapper("", ".foo%{record_type}.bar", ""), domain: "example.com", recordType: "A", txtDomain: "example.fooa.bar.com", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { txtDomain := tc.mapper.ToTXTName(tc.domain, tc.recordType) assert.Equal(t, tc.txtDomain, txtDomain) domain, _ := tc.mapper.ToEndpointName(txtDomain) assert.Equal(t, tc.domain, domain) }) } } func TestDropPrefix(t *testing.T) { mapper := NewAffixNameMapper("foo-%{record_type}-", "", "") expectedOutput := "test.example.com" tests := []string{ "foo-cname-test.example.com", "foo-a-test.example.com", "foo--test.example.com", } for _, tc := range tests { t.Run(tc, func(t *testing.T) { actualOutput, _ := mapper.dropAffixExtractType(tc) assert.Equal(t, expectedOutput, actualOutput) }) } } func TestDropSuffix(t *testing.T) { mapper := NewAffixNameMapper("", "-%{record_type}-foo", "") expectedOutput := "test.example.com" tests := []string{ "test-a-foo.example.com", "test--foo.example.com", } for _, tc := range tests { t.Run(tc, func(t *testing.T) { r := strings.SplitN(tc, ".", 2) rClean, _ := mapper.dropAffixExtractType(r[0]) actualOutput := rClean + "." + r[1] assert.Equal(t, expectedOutput, actualOutput) }) } } func TestExtractRecordTypeDefaultPosition(t *testing.T) { tests := []struct { input string expectedName string expectedType string }{ { input: "ns-zone.example.com", expectedName: "zone.example.com", expectedType: "NS", }, { input: "aaaa-zone.example.com", expectedName: "zone.example.com", expectedType: "AAAA", }, { input: "ptr-zone.example.com", expectedName: "zone.example.com", expectedType: "PTR", }, { input: "zone.example.com", expectedName: "zone.example.com", expectedType: "", }, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { actualName, actualType := extractRecordTypeDefaultPosition(tc.input) assert.Equal(t, tc.expectedName, actualName) assert.Equal(t, tc.expectedType, actualType) }) } } ================================================ FILE: registry/noop/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - noop ================================================ FILE: registry/noop/noop.go ================================================ /* Copyright 2017 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 noop import ( "context" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/registry" ) // NoopRegistry implements registry interface without ownership directly propagating changes to dns provider type NoopRegistry struct { provider provider.Provider } // New creates a NoopRegistry from the given configuration. func New(_ *externaldns.Config, p provider.Provider) (registry.Registry, error) { return newRegistry(p), nil } // newRegistry returns new NoopRegistry object func newRegistry(provider provider.Provider) *NoopRegistry { return &NoopRegistry{ provider: provider, } } func (im *NoopRegistry) GetDomainFilter() endpoint.DomainFilterInterface { return im.provider.GetDomainFilter() } func (im *NoopRegistry) OwnerID() string { return "" } // Records returns the current records from the dns provider func (im *NoopRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { return im.provider.Records(ctx) } // ApplyChanges propagates changes to the dns provider func (im *NoopRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { return im.provider.ApplyChanges(ctx, changes) } // AdjustEndpoints modifies the endpoints as needed by the specific provider func (im *NoopRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return im.provider.AdjustEndpoints(endpoints) } ================================================ FILE: registry/noop/noop_test.go ================================================ /* Copyright 2017 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 noop import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider/inmemory" ) func TestNoopRegistry(t *testing.T) { t.Run("NewNoopRegistry", testNoopInit) t.Run("Records", testNoopRecords) t.Run("ApplyChanges", testNoopApplyChanges) } func testNoopInit(t *testing.T) { p := inmemory.NewInMemoryProvider() r := newRegistry(p) var err error require.NoError(t, err) assert.Equal(t, p, r.provider) } func testNoopRecords(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() p.CreateZone("org") inmemoryRecords := []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"example-lb.com"}, RecordType: endpoint.RecordTypeCNAME, }, } p.ApplyChanges(ctx, &plan.Changes{ Create: inmemoryRecords, }) r := newRegistry(p) eps, err := r.Records(ctx) require.NoError(t, err) assert.True(t, testutils.SameEndpoints(eps, inmemoryRecords)) } func testNoopApplyChanges(t *testing.T) { // do some prep p := inmemory.NewInMemoryProvider() p.CreateZone("org") inmemoryRecords := []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"old-lb.com"}, RecordType: endpoint.RecordTypeCNAME, }, } expectedUpdate := []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"new-example-lb.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "new-record.org", Targets: endpoint.Targets{"new-lb.org"}, RecordType: endpoint.RecordTypeCNAME, }, } ctx := t.Context() p.ApplyChanges(ctx, &plan.Changes{ Create: inmemoryRecords, }) // wrong changes r := newRegistry(p) err := r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"lb.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }) assert.EqualError(t, err, inmemory.ErrRecordAlreadyExists.Error()) // correct changes require.NoError(t, r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "new-record.org", Targets: endpoint.Targets{"new-lb.org"}, RecordType: endpoint.RecordTypeCNAME, }, }, UpdateNew: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"new-example-lb.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, UpdateOld: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"old-lb.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, })) res, _ := p.Records(ctx) assert.True(t, testutils.SameEndpoints(res, expectedUpdate)) } ================================================ FILE: registry/registry.go ================================================ /* Copyright 2017 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" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) // Registry tracks ownership of DNS records managed by external-dns. type Registry interface { // Records returns all DNS records known to the registry, including ownership metadata. Records(ctx context.Context) ([]*endpoint.Endpoint, error) // ApplyChanges propagates the given changes to the DNS provider and updates ownership records accordingly. ApplyChanges(ctx context.Context, changes *plan.Changes) error // AdjustEndpoints normalises endpoints before they are processed by the planner. AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) // GetDomainFilter returns the domain filter configured for the underlying provider. GetDomainFilter() endpoint.DomainFilterInterface // OwnerID returns the owner identifier used to claim DNS records. OwnerID() string } ================================================ FILE: registry/txt/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - txt ================================================ FILE: registry/txt/encryption_test.go ================================================ /* Copyright 2017 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 txt import ( "fmt" "slices" "strconv" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider/inmemory" ) func TestNewTXTRegistryEncryptionConfig(t *testing.T) { p := inmemory.NewInMemoryProvider() tests := []struct { encEnabled bool aesKeyRaw []byte aesKeySanitized []byte errorExpected bool }{ { encEnabled: true, aesKeyRaw: []byte("123456789012345678901234567890asdfasdfasdfasdfa12"), aesKeySanitized: []byte{}, errorExpected: true, }, { encEnabled: true, aesKeyRaw: []byte("passphrasewhichneedstobe32bytes!"), aesKeySanitized: []byte("passphrasewhichneedstobe32bytes!"), errorExpected: false, }, { encEnabled: true, aesKeyRaw: []byte("ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY="), aesKeySanitized: []byte{100, 248, 173, 47, 67, 70, 85, 0, 89, 109, 48, 250, 15, 5, 201, 204, 63, 17, 137, 43, 82, 107, 60, 216, 93, 11, 29, 82, 140, 11, 81, 22}, errorExpected: false, }, } for _, test := range tests { actual, err := newRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, test.encEnabled, test.aesKeyRaw, "") if test.errorExpected { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, test.aesKeySanitized, actual.txtEncryptAESKey) } } } func TestGenerateTXTGenerateTextRecordEncryptionWihDecryption(t *testing.T) { p := inmemory.NewInMemoryProvider() _ = p.CreateZone(testZone) tests := []struct { record *endpoint.Endpoint decrypted string }{ { record: newEndpointWithOwner("foo.test-zone.example.org", "new-foo.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), decrypted: "heritage=external-dns,external-dns/owner=owner-2", }, { record: newEndpointWithOwnerAndLabels("foo.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "owner-1", endpoint.Labels{endpoint.OwnedRecordLabelKey: "foo.test-zone.example.org"}), decrypted: "heritage=external-dns,external-dns/ownedRecord=foo.test-zone.example.org,external-dns/owner=owner-1", }, { record: newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "cluster-b", endpoint.RecordTypeCNAME, "owner-1", endpoint.Labels{endpoint.ResourceLabelKey: "ingress/default/foo-127"}), decrypted: "heritage=external-dns,external-dns/owner=owner-1,external-dns/resource=ingress/default/foo-127", }, { record: newEndpointWithOwner("dualstack.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, "owner-0"), decrypted: "heritage=external-dns,external-dns/owner=owner-0", }, } withEncryptionKeys := []string{ "passphrasewhichneedstobe32bytes!", "ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=", "01234567890123456789012345678901", } for _, test := range tests { for _, k := range withEncryptionKeys { t.Run(fmt.Sprintf("key '%s' with decrypted result '%s'", k, test.decrypted), func(t *testing.T) { key := []byte(k) r, err := newRegistry(p, "", "", "owner", time.Minute, "", []string{}, []string{}, true, key, "") assert.NoError(t, err, "Error creating TXT registry") txtRecords := r.generateTXTRecord(test.record) assert.Len(t, txtRecords, len(test.record.Targets)) for _, txt := range txtRecords { // should return a TXT record with the encryption nonce label. At the moment nonce is not set as label. assert.NotContains(t, txt.Labels, "txt-encryption-nonce") assert.Len(t, txt.Targets, 1) assert.LessOrEqual(t, len(txt.Targets), 1) // decrypt targets for _, target := range txtRecords[0].Targets { encryptedText, errUnquote := strconv.Unquote(target) assert.NoError(t, errUnquote, "Error unquoting the encrypted text") actual, nonce, errDecrypt := endpoint.DecryptText(encryptedText, r.txtEncryptAESKey) assert.NoError(t, errDecrypt, "Error decrypting the encrypted text") assert.True(t, strings.HasPrefix(encryptedText, nonce), "Nonce '%s' should be a prefix of the encrypted text: '%s'", nonce, encryptedText) assert.Equal(t, test.decrypted, actual) } } }) } } } func TestApplyRecordsWithEncryption(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() _ = p.CreateZone("org") key := []byte("ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=") r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, key, "") _ = r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("new-record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "new-record-1.test-zone.example.org"), newEndpointWithOwner("example.org", "new-loadbalancer-3.org", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("main.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "example"), newEndpointWithOwner("tar.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), newEndpointWithOwner("thing3.org", "1.2.3.4", endpoint.RecordTypeA, "owner"), newEndpointWithOwner("thing4.org", "2001:DB8::2", endpoint.RecordTypeAAAA, "owner"), }, }) allPlainTextTargetsToAssert := []string{ "heritage=external-dns,external-dns/", "tar.loadbalancer.com", "new-loadbalancer-1.lb.com", "2001:DB8::2", "new-loadbalancer-3.org", "1.2.3.4", } records, _ := p.Records(ctx) assert.Len(t, records, 14) for _, r := range records { if r.RecordType == endpoint.RecordTypeTXT && (strings.HasPrefix(r.DNSName, "cname-") || strings.HasPrefix(r.DNSName, "txt-new-")) { assert.NotContains(t, r.Labels, "txt-encryption-nonce") // assuming single target, it should be not a plain text assert.NotContains(t, r.Targets[0], "heritage=external-dns") } // All TXT records with new- prefix should have the encryption nonce label and be in plain text if r.RecordType == endpoint.RecordTypeTXT && strings.HasPrefix(r.DNSName, "new-") { assert.Contains(t, r.Labels, "txt-encryption-nonce") // assuming single target, it should be in a plain text assert.Contains(t, r.Targets[0], "heritage=external-dns,external-dns/") } // All CNAME, A and AAAA TXT records should have the encryption nonce label if slices.Contains([]string{"CNAME", "A", "AAAA"}, r.RecordType) { assert.Contains(t, r.Labels, "txt-encryption-nonce") // validate that target is in plain text assert.Contains(t, allPlainTextTargetsToAssert, r.Targets[0]) } } } func TestApplyRecordsWithEncryptionKeyChanged(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() _ = p.CreateZone("org") withEncryptionKeys := []string{ "passphrasewhichneedstobe32bytes!", "ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=", "01234567890123456789012345678901", } for _, key := range withEncryptionKeys { r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte(key), "") _ = r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("new-record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "new-record-1.test-zone.example.org"), newEndpointWithOwner("example.org", "new-loadbalancer-3.org", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("main.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "example"), newEndpointWithOwner("tar.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), newEndpointWithOwner("thing3.org", "1.2.3.4", endpoint.RecordTypeA, "owner"), newEndpointWithOwner("thing4.org", "2001:DB8::2", endpoint.RecordTypeAAAA, "owner"), }, }) } records, _ := p.Records(ctx) assert.Len(t, records, 14) } func TestApplyRecordsOnEncryptionKeyChangeWithKeyIdLabel(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() _ = p.CreateZone("org") withEncryptionKeys := []string{ "passphrasewhichneedstobe32bytes!", "ZPitL0NGVQBZbTD6DwXJzD8RiStSazzYXQsdUowLURY=", "01234567890123456789012345678901", } for i, key := range withEncryptionKeys { r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte(key), "") keyId := fmt.Sprintf("key-id-%d", i) changes := []*endpoint.Endpoint{ newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner", "", keyId), newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel("new-record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "new-record-1.test-zone.example.org", keyId), newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel("example.org", "new-loadbalancer-3.org", endpoint.RecordTypeCNAME, "owner", "", keyId), newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel("main.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "", "example", keyId), newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel("tar.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2", "", keyId), newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel("thing3.org", "1.2.3.4", endpoint.RecordTypeA, "owner", "", keyId), newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel("thing4.org", "2001:DB8::2", endpoint.RecordTypeAAAA, "owner", "", keyId), } if i == 0 { _ = r.ApplyChanges(ctx, &plan.Changes{ Create: changes, }) } else { _ = r.ApplyChanges(t.Context(), &plan.Changes{ UpdateNew: changes, }) } } records, _ := p.Records(ctx) assert.Len(t, records, 14) encryptionNonce := map[string]bool{} for _, r := range records { if slices.Contains([]string{"A", "AAAA"}, r.RecordType) || (r.RecordType == "CNAME" && strings.HasPrefix(r.DNSName, "new-")) { assert.Contains(t, r.Labels, "key-id") assert.Equal(t, "key-id-2", r.Labels["key-id"]) // add encryption nonce to track the number of unique nonce encryptionNonce[r.Labels["txt-encryption-nonce"]] = true } else if r.RecordType == endpoint.RecordTypeTXT { if hasPrefixFromSlice(r.DNSName, []string{"cname-", "txt-new-", "a-", "aaaa-", "txt-"}) { assert.NotContains(t, r.Labels, "key-id") } else { assert.Contains(t, r.Labels, "key-id", r.DNSName) assert.Equal(t, "key-id-0", r.Labels["key-id"], r.DNSName) // add encryption nonce to track the number of unique nonce encryptionNonce[r.Labels["txt-encryption-nonce"]] = true } } } assert.LessOrEqual(t, len(encryptionNonce), 5) } func hasPrefixFromSlice(str string, prefixes []string) bool { for _, prefix := range prefixes { if strings.HasPrefix(str, prefix) { return true } } return false } func newEndpointWithOwnerAndOwnedRecordWithKeyIDLabel(dnsName, target, recordType, ownerID string, resource string, keyId string) *endpoint.Endpoint { e := endpoint.NewEndpoint(dnsName, recordType, target) e.Labels[endpoint.OwnerLabelKey] = ownerID e.Labels[endpoint.ResourceLabelKey] = resource e.Labels["key-id"] = keyId return e } ================================================ FILE: registry/txt/registry.go ================================================ /* Copyright 2017 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 txt import ( "context" "errors" "maps" "strings" "time" b64 "encoding/base64" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/registry" "sigs.k8s.io/external-dns/registry/mapper" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" ) const ( providerSpecificForceUpdate = "txt/force-update" ) // TXTRegistry implements registry interface with ownership implemented via associated TXT records type TXTRegistry struct { provider provider.Provider ownerID string // refers to the owner id of the current instance mapper mapper.NameMapper // cache the records in memory and update on an interval instead. recordsCache []*endpoint.Endpoint recordsCacheRefreshTime time.Time cacheInterval time.Duration // optional string to use to replace the asterisk in wildcard entries - without using this, // registry TXT records corresponding to wildcard records will be invalid (and rejected by most providers), due to // having a '*' appear (not as the first character) - see https://tools.ietf.org/html/rfc1034#section-4.3.3 wildcardReplacement string managedRecordTypes []string excludeRecordTypes []string // encrypt text records txtEncryptEnabled bool txtEncryptAESKey []byte // Handle Owner ID migration oldOwnerID string // existingTXTs is the TXT records that already exist in the zone so that // ApplyChanges() can skip re-creating them. See the struct below for details. existingTXTs *existingTXTs } // existingTXTs stores pre‑existing TXT records to avoid duplicate creation. // It relies on the fact that Records() is always called **before** ApplyChanges() // within a single reconciliation cycle. type existingTXTs struct { entries map[recordKey]struct{} } type recordKey struct { dnsName string setIdentifier string } func newExistingTXTs() *existingTXTs { return &existingTXTs{ entries: make(map[recordKey]struct{}), } } func (im *existingTXTs) add(r *endpoint.Endpoint) { key := recordKey{ dnsName: r.DNSName, setIdentifier: r.SetIdentifier, } im.entries[key] = struct{}{} } // isAbsent returns true when there is no entry for the given name in the store. // This is intended for the "if absent -> create" pattern. func (im *existingTXTs) isAbsent(ep *endpoint.Endpoint) bool { key := recordKey{ dnsName: ep.DNSName, setIdentifier: ep.SetIdentifier, } _, ok := im.entries[key] return !ok } func (im *existingTXTs) reset() { // Reset the existing TXT records for the next reconciliation loop. // This is necessary because the existing TXT records are only relevant for the current reconciliation cycle. im.entries = make(map[recordKey]struct{}) } // New creates a TXTRegistry from the given configuration. func New(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) { return newRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, cfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey), cfg.TXTOwnerOld) } // newRegistry returns a new TXTRegistry object. When newFormatOnly is true, it will only // generate new format TXT records, otherwise it generates both old and new formats for // backwards compatibility. func newRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID string, cacheInterval time.Duration, txtWildcardReplacement string, managedRecordTypes, excludeRecordTypes []string, txtEncryptEnabled bool, txtEncryptAESKey []byte, oldOwnerID string) (*TXTRegistry, error) { if ownerID == "" { return nil, errors.New("owner id cannot be empty") } // TODO: encryption logic duplicated in DynamoDB registry; refactor into common utility function. if len(txtEncryptAESKey) == 0 { txtEncryptAESKey = nil } else if len(txtEncryptAESKey) != 32 { var err error if txtEncryptAESKey, err = b64.StdEncoding.DecodeString(string(txtEncryptAESKey)); err != nil || len(txtEncryptAESKey) != 32 { return nil, errors.New("the AES Encryption key must be 32 bytes long, in either plain text or base64-encoded format") } } if txtEncryptEnabled && txtEncryptAESKey == nil { return nil, errors.New("the AES Encryption key must be set when TXT record encryption is enabled") } if len(txtPrefix) > 0 && len(txtSuffix) > 0 { return nil, errors.New("txt-prefix and txt-suffix are mutual exclusive") } return &TXTRegistry{ provider: provider, ownerID: ownerID, mapper: mapper.NewAffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement), cacheInterval: cacheInterval, wildcardReplacement: txtWildcardReplacement, managedRecordTypes: managedRecordTypes, excludeRecordTypes: excludeRecordTypes, txtEncryptEnabled: txtEncryptEnabled, txtEncryptAESKey: txtEncryptAESKey, oldOwnerID: oldOwnerID, existingTXTs: newExistingTXTs(), }, nil } func (im *TXTRegistry) GetDomainFilter() endpoint.DomainFilterInterface { return im.provider.GetDomainFilter() } func (im *TXTRegistry) OwnerID() string { return im.ownerID } // Records returns the current records from the registry excluding TXT Records // If TXT records was created previously to indicate ownership its corresponding value // will be added to the endpoints Labels map func (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { // existingTXTs must always hold the latest TXT records, so it needs to be reset every time. // Previously, it was reset with a defer after ApplyChanges, but ApplyChanges is not called // when plan.HasChanges() is false (i.e., when there are no changes to apply). // In that case, stale TXT record information could remain, so we reset it here instead. im.existingTXTs.reset() // If we have the zones cached AND we have refreshed the cache since the // last given interval, then just use the cached results. if im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval { log.Debug("Using cached records.") return im.recordsCache, nil } records, err := im.provider.Records(ctx) if err != nil { return nil, err } endpoints := []*endpoint.Endpoint{} labelMap := map[endpoint.EndpointKey]endpoint.Labels{} txtRecordsMap := map[string]struct{}{} for _, record := range records { if record.RecordType != endpoint.RecordTypeTXT { endpoints = append(endpoints, record) continue } // We simply assume that TXT records for the registry will always have only one target. // If there are no targets (e.g for routing policy based records in google), direct targets will be empty if len(record.Targets) == 0 { log.Errorf("TXT record has no targets %s", record.DNSName) continue } labels, err := endpoint.NewLabelsFromString(record.Targets[0], im.txtEncryptAESKey) if errors.Is(err, endpoint.ErrInvalidHeritage) { // if no heritage is found or it is invalid // case when value of txt record cannot be identified // record will not be removed as it will have empty owner endpoints = append(endpoints, record) continue } if err != nil { return nil, err } endpointName, recordType := im.mapper.ToEndpointName(record.DNSName) key := endpoint.EndpointKey{ DNSName: endpointName, RecordType: recordType, SetIdentifier: record.SetIdentifier, } labelMap[key] = labels txtRecordsMap[record.DNSName] = struct{}{} im.existingTXTs.add(record) } for _, ep := range endpoints { if ep.Labels == nil { ep.Labels = endpoint.NewLabels() } dnsNameSplit := strings.Split(ep.DNSName, ".") // If specified, replace a leading asterisk in the generated txt record name with some other string if im.wildcardReplacement != "" && dnsNameSplit[0] == "*" { dnsNameSplit[0] = im.wildcardReplacement } dnsName := strings.Join(dnsNameSplit, ".") key := endpoint.EndpointKey{ DNSName: dnsName, RecordType: ep.RecordType, SetIdentifier: ep.SetIdentifier, } // AWS Alias records have "new" format encoded as type "cname" if isAlias, found := ep.GetBoolProviderSpecificProperty("alias"); found && isAlias && ep.RecordType == endpoint.RecordTypeA { key.RecordType = endpoint.RecordTypeCNAME } // Handle both new and old registry format with the preference for the new one labels, labelsExist := labelMap[key] if !labelsExist && ep.RecordType != endpoint.RecordTypeAAAA { key.RecordType = "" labels, labelsExist = labelMap[key] } if labelsExist { maps.Copy(ep.Labels, labels) } if im.oldOwnerID != "" && ep.Labels[endpoint.OwnerLabelKey] == im.oldOwnerID { ep.Labels[endpoint.OwnerLabelKey] = im.ownerID } // TODO: remove this migration logic in some future release // Handle the migration of TXT records created before the new format (introduced in v0.12.0). // The migration is done for the TXT records owned by this instance only. if len(txtRecordsMap) > 0 && ep.Labels[endpoint.OwnerLabelKey] == im.ownerID { if plan.IsManagedRecord(ep.RecordType, im.managedRecordTypes, im.excludeRecordTypes) { // Get desired TXT records and detect the missing ones desiredTXTs := im.generateTXTRecord(ep) for _, desiredTXT := range desiredTXTs { if _, exists := txtRecordsMap[desiredTXT.DNSName]; !exists { ep.WithProviderSpecific(providerSpecificForceUpdate, "true") } } } } } // Update the cache. if im.cacheInterval > 0 { im.recordsCache = endpoints im.recordsCacheRefreshTime = time.Now() } return endpoints, nil } // generateTXTRecord generates TXT records in either both formats (old and new) or new format only, // depending on the newFormatOnly configuration. The old format is maintained for backwards // compatibility but can be disabled to reduce the number of DNS records. func (im *TXTRegistry) generateTXTRecord(r *endpoint.Endpoint) []*endpoint.Endpoint { return im.generateTXTRecordWithFilter(r, func(_ *endpoint.Endpoint) bool { return true }) } func (im *TXTRegistry) generateTXTRecordWithFilter(r *endpoint.Endpoint, filter func(*endpoint.Endpoint) bool) []*endpoint.Endpoint { endpoints := make([]*endpoint.Endpoint, 0) // Always create new format record recordType := r.RecordType // AWS Alias records are encoded as type "cname" if isAlias, found := r.GetBoolProviderSpecificProperty("alias"); found && isAlias && recordType == endpoint.RecordTypeA { recordType = endpoint.RecordTypeCNAME } if im.oldOwnerID != "" && r.Labels[endpoint.OwnerLabelKey] == im.oldOwnerID { r.Labels[endpoint.OwnerLabelKey] = im.ownerID } txtNew := endpoint.NewEndpoint(im.mapper.ToTXTName(r.DNSName, recordType), endpoint.RecordTypeTXT, r.Labels.Serialize(true, im.txtEncryptEnabled, im.txtEncryptAESKey)) if txtNew != nil { txtNew.WithSetIdentifier(r.SetIdentifier) txtNew.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName txtNew.ProviderSpecific = r.ProviderSpecific if filter(txtNew) { endpoints = append(endpoints, txtNew) } } return endpoints } // ApplyChanges updates dns provider with the changes // for each created/deleted record it will also take into account TXT records for creation/deletion func (im *TXTRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { filteredChanges := &plan.Changes{ Create: changes.Create, UpdateNew: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateNew), UpdateOld: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateOld), Delete: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.Delete), } for _, r := range filteredChanges.Create { if r.Labels == nil { r.Labels = make(map[string]string) } r.Labels[endpoint.OwnerLabelKey] = im.ownerID filteredChanges.Create = append(filteredChanges.Create, im.generateTXTRecordWithFilter(r, im.existingTXTs.isAbsent)...) if im.cacheInterval > 0 { im.addToCache(r) } } for _, r := range filteredChanges.Delete { // when we delete TXT records for which value has changed (due to new label) this would still work because // !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed // !!! After migration to the new TXT registry format we can drop records in old format here!!! filteredChanges.Delete = append(filteredChanges.Delete, im.generateTXTRecord(r)...) if im.cacheInterval > 0 { im.removeFromCache(r) } } // make sure TXT records are consistently updated as well for _, r := range filteredChanges.UpdateOld { // when we updateOld TXT records for which value has changed (due to new label) this would still work because // !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed filteredChanges.UpdateOld = append(filteredChanges.UpdateOld, im.generateTXTRecord(r)...) // remove old version of record from cache if im.cacheInterval > 0 { im.removeFromCache(r) } } // make sure TXT records are consistently updated as well for _, r := range filteredChanges.UpdateNew { filteredChanges.UpdateNew = append(filteredChanges.UpdateNew, im.generateTXTRecord(r)...) // add new version of record to cache if im.cacheInterval > 0 { im.addToCache(r) } } // when caching is enabled, disable the provider from using the cache if im.cacheInterval > 0 { ctx = context.WithValue(ctx, provider.RecordsContextKey, nil) } return im.provider.ApplyChanges(ctx, filteredChanges) } // AdjustEndpoints modifies the endpoints as needed by the specific provider func (im *TXTRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { return im.provider.AdjustEndpoints(endpoints) } func (im *TXTRegistry) addToCache(ep *endpoint.Endpoint) { if im.recordsCache != nil { im.recordsCache = append(im.recordsCache, ep) } } func (im *TXTRegistry) removeFromCache(ep *endpoint.Endpoint) { if im.recordsCache == nil || ep == nil { return } for i, e := range im.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. im.recordsCache = append(im.recordsCache[:i], im.recordsCache[i+1:]...) return } } } ================================================ FILE: registry/txt/registry_test.go ================================================ /* Copyright 2017 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 txt import ( "context" "fmt" "reflect" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/registry/mapper" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/provider/inmemory" ) const ( testZone = "test-zone.example.org" ) func TestTXTRegistry(t *testing.T) { t.Run("TestNewTXTRegistry", testTXTRegistryNew) t.Run("TestRecords", testTXTRegistryRecords) t.Run("TestApplyChanges", testTXTRegistryApplyChanges) t.Run("TestMissingRecords", testTXTRegistryMissingRecords) } func testTXTRegistryNew(t *testing.T) { p := inmemory.NewInMemoryProvider() _, err := newRegistry(p, "txt", "", "", time.Hour, "", []string{}, []string{}, false, nil, "") require.Error(t, err) _, err = newRegistry(p, "", "txt", "", time.Hour, "", []string{}, []string{}, false, nil, "") require.Error(t, err) r, err := newRegistry(p, "txt", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") require.NoError(t, err) assert.Equal(t, p, r.provider) r, err = newRegistry(p, "", "txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") require.NoError(t, err) _, err = newRegistry(p, "txt", "txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") require.Error(t, err) _, ok := r.mapper.(mapper.AffixNameMapper) require.True(t, ok) assert.Equal(t, "owner", r.ownerID) assert.Equal(t, p, r.provider) aesKey := []byte(";k&l)nUC/33:{?d{3)54+,AD?]SX%yh^") _, err = newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") require.NoError(t, err) _, err = newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, aesKey, "") require.NoError(t, err) _, err = newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, nil, "") require.Error(t, err) r, err = newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, true, aesKey, "") require.NoError(t, err) _, ok = r.mapper.(mapper.AffixNameMapper) assert.True(t, ok) } func testTXTRegistryRecords(t *testing.T) { t.Run("With prefix", testTXTRegistryRecordsPrefixed) t.Run("With suffix", testTXTRegistryRecordsSuffixed) t.Run("No prefix", testTXTRegistryRecordsNoPrefix) t.Run("With templated prefix", testTXTRegistryRecordsPrefixedTemplated) t.Run("With templated suffix", testTXTRegistryRecordsSuffixedTemplated) } func testTXTRegistryRecordsPrefixed(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwnerAndLabels("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"foo": "somefoo"}), newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"bar": "somebar"}), newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwnerAndLabels("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"tar": "sometar"}), newEndpointWithOwner("TxT.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), // case-insensitive TXT prefix newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("*.wildcard.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.wc.wildcard.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("dualstack.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), newEndpointWithOwner("txt.dualstack.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("dualstack.test-zone.example.org", "2001:DB8::1", endpoint.RecordTypeAAAA, ""), newEndpointWithOwner("txt.aaaa-dualstack.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("mail.test-zone.example.org", "10 onemail.example.com", endpoint.RecordTypeMX, ""), newEndpointWithOwner("txt.mx-mail.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newMultiTargetEndpointWithOwner( "_sip._udp.sip1.test-zone.example.org", []string{"1 50 5060 sip1-n1.test-zone.example.org", "1 50 5060 sip1-n2.test-zone.example.org"}, endpoint.RecordTypeSRV, "", ), newEndpointWithOwner("txt._sip._udp.sip1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("sip1.test-zone.example.org", `10 "U" "SIP+DTU" "" _sip._udp.sip1.test-zone.example.org.`, endpoint.RecordTypeNAPTR, ""), newEndpointWithOwner("txt.sip1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) require.NoError(t, err) expectedRecords := []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", "foo": "somefoo", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", "bar": "somebar", }, }, { DNSName: "txt.bar.test-zone.example.org", Targets: endpoint.Targets{"baz.test-zone.example.org"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "qux.test-zone.example.org", Targets: endpoint.Targets{"random"}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "tar.test-zone.example.org", Targets: endpoint.Targets{"tar.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner-2", "tar": "sometar", }, }, { DNSName: "foobar.test-zone.example.org", Targets: endpoint.Targets{"foobar.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "multiple.test-zone.example.org", Targets: endpoint.Targets{"lb1.loadbalancer.com"}, SetIdentifier: "test-set-1", RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "multiple.test-zone.example.org", Targets: endpoint.Targets{"lb2.loadbalancer.com"}, SetIdentifier: "test-set-2", RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "*.wildcard.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "dualstack.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "dualstack.test-zone.example.org", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: endpoint.RecordTypeAAAA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner-2", }, }, { DNSName: "mail.test-zone.example.org", Targets: endpoint.Targets{"10 onemail.example.com"}, RecordType: endpoint.RecordTypeMX, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "_sip._udp.sip1.test-zone.example.org", Targets: endpoint.Targets{ "1 50 5060 sip1-n1.test-zone.example.org", "1 50 5060 sip1-n2.test-zone.example.org", }, RecordType: endpoint.RecordTypeSRV, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "sip1.test-zone.example.org", Targets: endpoint.Targets{`10 "U" "SIP+DTU" "" _sip._udp.sip1.test-zone.example.org.`}, RecordType: endpoint.RecordTypeNAPTR, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, } r, _ := newRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) // Ensure prefix is case-insensitive r, _ = newRegistry(p, "TxT.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") records, _ = r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) } func testTXTRegistryRecordsSuffixed(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwnerAndLabels("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"foo": "somefoo"}), newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"bar": "somebar"}), newEndpointWithOwner("bar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("bar-txt.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwnerAndLabels("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "", endpoint.Labels{"tar": "sometar"}), newEndpointWithOwner("tar-TxT.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), // case-insensitive TXT prefix newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("dualstack.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), newEndpointWithOwner("dualstack-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("dualstack.test-zone.example.org", "2001:DB8::1", endpoint.RecordTypeAAAA, ""), newEndpointWithOwner("aaaa-dualstack-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("mail.test-zone.example.org", "10 onemail.example.com", endpoint.RecordTypeMX, ""), newEndpointWithOwner("mx-mail-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newMultiTargetEndpointWithOwner( "_sip._udp.sip1.test-zone.example.org", []string{"1 50 5060 sip1-n1.test-zone.example.org", "1 50 5060 sip1-n2.test-zone.example.org"}, endpoint.RecordTypeSRV, "", ), newEndpointWithOwner("_sip-txt._udp.sip1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("sip1.test-zone.example.org", `10 "U" "SIP+DTU" "" _sip._udp.sip1.test-zone.example.org.`, endpoint.RecordTypeNAPTR, ""), newEndpointWithOwner("sip1-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) require.NoError(t, err) expectedRecords := []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", "foo": "somefoo", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", "bar": "somebar", }, }, { DNSName: "bar-txt.test-zone.example.org", Targets: endpoint.Targets{"baz.test-zone.example.org"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "qux.test-zone.example.org", Targets: endpoint.Targets{"random"}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "tar.test-zone.example.org", Targets: endpoint.Targets{"tar.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner-2", "tar": "sometar", }, }, { DNSName: "foobar.test-zone.example.org", Targets: endpoint.Targets{"foobar.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "multiple.test-zone.example.org", Targets: endpoint.Targets{"lb1.loadbalancer.com"}, SetIdentifier: "test-set-1", RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "multiple.test-zone.example.org", Targets: endpoint.Targets{"lb2.loadbalancer.com"}, SetIdentifier: "test-set-2", RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "dualstack.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "dualstack.test-zone.example.org", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: endpoint.RecordTypeAAAA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner-2", }, }, { DNSName: "mail.test-zone.example.org", Targets: endpoint.Targets{"10 onemail.example.com"}, RecordType: endpoint.RecordTypeMX, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "_sip._udp.sip1.test-zone.example.org", Targets: endpoint.Targets{ "1 50 5060 sip1-n1.test-zone.example.org", "1 50 5060 sip1-n2.test-zone.example.org", }, RecordType: endpoint.RecordTypeSRV, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "sip1.test-zone.example.org", Targets: endpoint.Targets{`10 "U" "SIP+DTU" "" _sip._udp.sip1.test-zone.example.org.`}, RecordType: endpoint.RecordTypeNAPTR, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, } r, _ := newRegistry(p, "", "-txt", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) // Ensure prefix is case-insensitive r, _ = newRegistry(p, "", "-TxT", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") records, _ = r.Records(ctx) assert.True(t, testutils.SameEndpointLabels(records, expectedRecords)) } func testTXTRegistryRecordsNoPrefix(t *testing.T) { p := inmemory.NewInMemoryProvider() ctx := t.Context() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "").WithProviderSpecific("alias", "true"), newEndpointWithOwner("cname-alias.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("dualstack.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), newEndpointWithOwner("dualstack.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("dualstack.test-zone.example.org", "2001:DB8::1", endpoint.RecordTypeAAAA, ""), newEndpointWithOwner("aaaa-dualstack.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("mail.test-zone.example.org", "10 onemail.example.com", endpoint.RecordTypeMX, ""), newEndpointWithOwner("mx-mail.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newMultiTargetEndpointWithOwner( "_sip._udp.sip1.test-zone.example.org", []string{"1 50 5060 sip1-n1.test-zone.example.org", "1 50 5060 sip1-n2.test-zone.example.org"}, endpoint.RecordTypeSRV, "", ), newEndpointWithOwner("_sip._udp.sip1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("sip1.test-zone.example.org", `10 "U" "SIP+DTU" "" _sip._udp.sip1.test-zone.example.org.`, endpoint.RecordTypeNAPTR, ""), newEndpointWithOwner("sip1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) require.NoError(t, err) expectedRecords := []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "alias.test-zone.example.org", Targets: endpoint.Targets{"my-domain.com"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, ProviderSpecific: []endpoint.ProviderSpecificProperty{ { Name: "alias", Value: "true", }, }, }, { DNSName: "txt.bar.test-zone.example.org", Targets: endpoint.Targets{"baz.test-zone.example.org"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", endpoint.ResourceLabelKey: "ingress/default/my-ingress", }, }, { DNSName: "qux.test-zone.example.org", Targets: endpoint.Targets{"random"}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "tar.test-zone.example.org", Targets: endpoint.Targets{"tar.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "", }, }, { DNSName: "foobar.test-zone.example.org", Targets: endpoint.Targets{"foobar.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "dualstack.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "dualstack.test-zone.example.org", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: endpoint.RecordTypeAAAA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner-2", }, }, { DNSName: "mail.test-zone.example.org", Targets: endpoint.Targets{"10 onemail.example.com"}, RecordType: endpoint.RecordTypeMX, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "_sip._udp.sip1.test-zone.example.org", Targets: endpoint.Targets{ "1 50 5060 sip1-n1.test-zone.example.org", "1 50 5060 sip1-n2.test-zone.example.org", }, RecordType: endpoint.RecordTypeSRV, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "sip1.test-zone.example.org", Targets: endpoint.Targets{`10 "U" "SIP+DTU" "" _sip._udp.sip1.test-zone.example.org.`}, RecordType: endpoint.RecordTypeNAPTR, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, } r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) } func testTXTRegistryRecordsPrefixedTemplated(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), newEndpointWithOwner("txt-a.foo.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("mail.test-zone.example.org", "10 onemail.example.com", endpoint.RecordTypeMX, ""), newEndpointWithOwner("txt-mx.mail.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) require.NoError(t, err) expectedRecords := []*endpoint.Endpoint{ { DNSName: "foo.test-zone.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "mail.test-zone.example.org", Targets: endpoint.Targets{"10 onemail.example.com"}, RecordType: endpoint.RecordTypeMX, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, } r, _ := newRegistry(p, "txt-%{record_type}.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) r, _ = newRegistry(p, "TxT-%{record_type}.", "", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") records, _ = r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) } func testTXTRegistryRecordsSuffixedTemplated(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("bar.test-zone.example.org", "8.8.8.8", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bartxtcname.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("mail.test-zone.example.org", "10 onemail.example.com", endpoint.RecordTypeMX, ""), newEndpointWithOwner("mailtxt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) require.NoError(t, err) expectedRecords := []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "mail.test-zone.example.org", Targets: endpoint.Targets{"10 onemail.example.com"}, RecordType: endpoint.RecordTypeMX, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, } r, _ := newRegistry(p, "", "txt%{record_type}", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) r, _ = newRegistry(p, "", "TxT%{record_type}", "owner", time.Hour, "wc", []string{}, []string{}, false, nil, "") records, _ = r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) } func testTXTRegistryApplyChanges(t *testing.T) { t.Run("With Prefix", testTXTRegistryApplyChangesWithPrefix) t.Run("With Templated Prefix", testTXTRegistryApplyChangesWithTemplatedPrefix) t.Run("With Templated Suffix", testTXTRegistryApplyChangesWithTemplatedSuffix) t.Run("With Suffix", testTXTRegistryApplyChangesWithSuffix) t.Run("No prefix", testTXTRegistryApplyChangesNoPrefix) } func testTXTRegistryApplyChangesWithPrefix(t *testing.T) { p := inmemory.NewInMemoryProvider() _ = p.CreateZone(testZone) var ctxEndpoints []*endpoint.Endpoint ctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints) p.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) { assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) } _ = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.cname-tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("txt.multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("txt.multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), }, }) r, _ := newRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") changes := &plan.Changes{ Create: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), newCNAMEEndpointWithOwnerResource("multiple.test-zone.example.org", "lb3.loadbalancer.com", "owner", "ingress/default/my-ingress").WithSetIdentifier("test-set-3"), newCNAMEEndpointWithOwnerResource("example", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-1"), }, UpdateNew: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("tar.test-zone.example.org", "new-tar.loadbalancer.com", "owner", "ingress/default/my-ingress-2"), newCNAMEEndpointWithOwnerResource("multiple.test-zone.example.org", "new.loadbalancer.com", "owner", "ingress/default/my-ingress-2").WithSetIdentifier("test-set-2"), }, UpdateOld: []*endpoint.Endpoint{ newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-2"), }, } expected := &plan.Changes{ Create: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), newTXTEndpointWithOwnedRecord("txt.cname-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", "new-record-1.test-zone.example.org"), newCNAMEEndpointWithOwnerResource("multiple.test-zone.example.org", "lb3.loadbalancer.com", "owner", "ingress/default/my-ingress").WithSetIdentifier("test-set-3"), newTXTEndpointWithOwnedRecord("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", "multiple.test-zone.example.org").WithSetIdentifier("test-set-3"), newCNAMEEndpointWithOwnerResource("example", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), newTXTEndpointWithOwnedRecord("txt.cname-example", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", "example"), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("txt.cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "foobar.test-zone.example.org"), newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-1"), newTXTEndpointWithOwnedRecord("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "multiple.test-zone.example.org").WithSetIdentifier("test-set-1"), }, UpdateNew: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("tar.test-zone.example.org", "new-tar.loadbalancer.com", "owner", "ingress/default/my-ingress-2"), newTXTEndpointWithOwnedRecord("txt.cname-tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", "tar.test-zone.example.org"), newCNAMEEndpointWithOwnerResource("multiple.test-zone.example.org", "new.loadbalancer.com", "owner", "ingress/default/my-ingress-2").WithSetIdentifier("test-set-2"), newTXTEndpointWithOwnedRecord("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), }, UpdateOld: []*endpoint.Endpoint{ newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("txt.cname-tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "tar.test-zone.example.org"), newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-2"), newTXTEndpointWithOwnedRecord("txt.cname-multiple.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), }, } p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, "UpdateOld": expected.UpdateOld, "Delete": expected.Delete, } mGot := map[string][]*endpoint.Endpoint{ "Create": got.Create, "UpdateNew": got.UpdateNew, "UpdateOld": got.UpdateOld, "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) assert.Nil(t, ctx.Value(provider.RecordsContextKey)) } err := r.ApplyChanges(ctx, changes) require.NoError(t, err) } func testTXTRegistryApplyChangesWithTemplatedPrefix(t *testing.T) { p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) var ctxEndpoints []*endpoint.Endpoint ctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints) p.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) { assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) } _ = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{}, }) r, _ := newRegistry(p, "prefix%{record_type}.", "", "owner-1", time.Hour, "", []string{}, []string{}, false, nil, "") changes := &plan.Changes{ Create: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner-1", "ingress/default/my-ingress"), }, Delete: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, } expected := &plan.Changes{ Create: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner-1", "ingress/default/my-ingress"), newTXTEndpointWithOwnedRecord("prefixcname.new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-1,external-dns/resource=ingress/default/my-ingress\"", "new-record-1.test-zone.example.org"), }, } p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, "UpdateOld": expected.UpdateOld, "Delete": expected.Delete, } mGot := map[string][]*endpoint.Endpoint{ "Create": got.Create, "UpdateNew": got.UpdateNew, "UpdateOld": got.UpdateOld, "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) assert.Nil(t, ctx.Value(provider.RecordsContextKey)) } err = r.ApplyChanges(ctx, changes) require.NoError(t, err) } func testTXTRegistryApplyChangesWithTemplatedSuffix(t *testing.T) { p := inmemory.NewInMemoryProvider() _ = p.CreateZone(testZone) var ctxEndpoints []*endpoint.Endpoint ctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints) p.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) { assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) } r, _ := newRegistry(p, "", "-%{record_type}suffix", "owner-2", time.Hour, "", []string{}, []string{}, false, nil, "") changes := &plan.Changes{ Create: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner-2", "ingress/default/my-ingress"), }, Delete: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, UpdateNew: []*endpoint.Endpoint{}, } expected := &plan.Changes{ Create: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner-2", "ingress/default/my-ingress"), newTXTEndpointWithOwnedRecord("new-record-1-cnamesuffix.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner-2,external-dns/resource=ingress/default/my-ingress\"", "new-record-1.test-zone.example.org"), }, } p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, "UpdateOld": expected.UpdateOld, "Delete": expected.Delete, } mGot := map[string][]*endpoint.Endpoint{ "Create": got.Create, "UpdateNew": got.UpdateNew, "UpdateOld": got.UpdateOld, "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) assert.Nil(t, ctx.Value(provider.RecordsContextKey)) } err := r.ApplyChanges(ctx, changes) require.NoError(t, err) } func testTXTRegistryApplyChangesWithSuffix(t *testing.T) { p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) var ctxEndpoints []*endpoint.Endpoint ctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints) p.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) { assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) } err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar-txt.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("cname-bar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("cname-tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("foobar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("cname-foobar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-1"), newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), newEndpointWithOwner("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, "").WithSetIdentifier("test-set-2"), }, }) require.NoError(t, err) r, _ := newRegistry(p, "", "-txt", "owner", time.Hour, "wildcard", []string{}, []string{}, false, nil, "") changes := &plan.Changes{ Create: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), newCNAMEEndpointWithOwnerResource("multiple.test-zone.example.org", "lb3.loadbalancer.com", "owner", "ingress/default/my-ingress").WithSetIdentifier("test-set-3"), newCNAMEEndpointWithOwnerResource("example", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), newCNAMEEndpointWithOwnerResource("*.wildcard.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-1"), }, UpdateNew: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("tar.test-zone.example.org", "new-tar.loadbalancer.com", "owner", "ingress/default/my-ingress-2"), newCNAMEEndpointWithOwnerResource("multiple.test-zone.example.org", "new.loadbalancer.com", "owner", "ingress/default/my-ingress-2").WithSetIdentifier("test-set-2"), }, UpdateOld: []*endpoint.Endpoint{ newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-2"), }, } expected := &plan.Changes{ Create: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), newTXTEndpointWithOwnedRecord("cname-new-record-1-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", "new-record-1.test-zone.example.org"), newCNAMEEndpointWithOwnerResource("multiple.test-zone.example.org", "lb3.loadbalancer.com", "owner", "ingress/default/my-ingress").WithSetIdentifier("test-set-3"), newTXTEndpointWithOwnedRecord("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", "multiple.test-zone.example.org").WithSetIdentifier("test-set-3"), newCNAMEEndpointWithOwnerResource("example", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), newTXTEndpointWithOwnedRecord("cname-example-txt", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", "example"), newCNAMEEndpointWithOwnerResource("*.wildcard.test-zone.example.org", "new-loadbalancer-1.lb.com", "owner", "ingress/default/my-ingress"), newTXTEndpointWithOwnedRecord("cname-wildcard-txt.wildcard.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress\"", "*.wildcard.test-zone.example.org"), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("cname-foobar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "foobar.test-zone.example.org"), newEndpointWithOwner("multiple.test-zone.example.org", "lb1.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-1"), newTXTEndpointWithOwnedRecord("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "multiple.test-zone.example.org").WithSetIdentifier("test-set-1"), }, UpdateNew: []*endpoint.Endpoint{ newCNAMEEndpointWithOwnerResource("tar.test-zone.example.org", "new-tar.loadbalancer.com", "owner", "ingress/default/my-ingress-2"), newTXTEndpointWithOwnedRecord("cname-tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", "tar.test-zone.example.org"), newCNAMEEndpointWithOwnerResource("multiple.test-zone.example.org", "new.loadbalancer.com", "owner", "ingress/default/my-ingress-2").WithSetIdentifier("test-set-2"), newTXTEndpointWithOwnedRecord("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner,external-dns/resource=ingress/default/my-ingress-2\"", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), }, UpdateOld: []*endpoint.Endpoint{ newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("cname-tar-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "tar.test-zone.example.org"), newEndpointWithOwner("multiple.test-zone.example.org", "lb2.loadbalancer.com", endpoint.RecordTypeCNAME, "owner").WithSetIdentifier("test-set-2"), newTXTEndpointWithOwnedRecord("cname-multiple-txt.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "multiple.test-zone.example.org").WithSetIdentifier("test-set-2"), }, } p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, "UpdateOld": expected.UpdateOld, "Delete": expected.Delete, } mGot := map[string][]*endpoint.Endpoint{ "Create": got.Create, "UpdateNew": got.UpdateNew, "UpdateOld": got.UpdateOld, "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) assert.Nil(t, ctx.Value(provider.RecordsContextKey)) } err = r.ApplyChanges(ctx, changes) require.NoError(t, err) } func testTXTRegistryApplyChangesNoPrefix(t *testing.T) { p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) var ctxEndpoints []*endpoint.Endpoint ctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints) p.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) { assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) } err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) require.NoError(t, err) r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") changes := &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("new-alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "").WithProviderSpecific("alias", "true"), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), }, UpdateNew: []*endpoint.Endpoint{ newEndpointWithOwner("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), }, UpdateOld: []*endpoint.Endpoint{ newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), }, } expected := &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("cname-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "new-record-1.test-zone.example.org"), newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("cname-example", "\"heritage=external-dns,external-dns/owner=owner\"", "example"), newEndpointWithOwner("new-alias.test-zone.example.org", "my-domain.com", endpoint.RecordTypeA, "owner").WithProviderSpecific("alias", "true"), newTXTEndpointWithOwnedRecord("cname-new-alias.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "new-alias.test-zone.example.org").WithProviderSpecific("alias", "true"), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "foobar.test-zone.example.org"), }, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, } p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, "UpdateOld": expected.UpdateOld, "Delete": expected.Delete, } mGot := map[string][]*endpoint.Endpoint{ "Create": got.Create, "UpdateNew": got.UpdateNew, "UpdateOld": got.UpdateOld, "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) assert.Nil(t, ctx.Value(provider.RecordsContextKey)) } err = r.ApplyChanges(ctx, changes) require.NoError(t, err) } func testTXTRegistryMissingRecords(t *testing.T) { t.Run("No prefix", testTXTRegistryMissingRecordsNoPrefix) t.Run("With Prefix", testTXTRegistryMissingRecordsWithPrefix) } func testTXTRegistryMissingRecordsNoPrefix(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("oldformat.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("oldformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("oldformat2.test-zone.example.org", "bar.loadbalancer.com", endpoint.RecordTypeA, ""), newEndpointWithOwner("oldformat2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("newformat.test-zone.example.org", "foobar.nameserver.com", endpoint.RecordTypeNS, ""), newEndpointWithOwner("ns-newformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("newformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("noheritage.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("oldformat-otherowner.test-zone.example.org", "bar.loadbalancer.com", endpoint.RecordTypeA, ""), newEndpointWithOwner("oldformat-otherowner.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=otherowner\"", endpoint.RecordTypeTXT, ""), endpoint.NewEndpoint("unmanaged1.test-zone.example.org", endpoint.RecordTypeA, "unmanaged1.loadbalancer.com"), endpoint.NewEndpoint("unmanaged2.test-zone.example.org", endpoint.RecordTypeCNAME, "unmanaged2.loadbalancer.com"), newEndpointWithOwner("this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) require.NoError(t, err) expectedRecords := []*endpoint.Endpoint{ { DNSName: "oldformat.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ // owner was added from the TXT record's target endpoint.OwnerLabelKey: "owner", }, ProviderSpecific: []endpoint.ProviderSpecificProperty{ { Name: "txt/force-update", Value: "true", }, }, }, { DNSName: "oldformat2.test-zone.example.org", Targets: endpoint.Targets{"bar.loadbalancer.com"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, ProviderSpecific: []endpoint.ProviderSpecificProperty{ { Name: "txt/force-update", Value: "true", }, }, }, { DNSName: "newformat.test-zone.example.org", Targets: endpoint.Targets{"foobar.nameserver.com"}, RecordType: endpoint.RecordTypeNS, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, // Only TXT records with the wrong heritage are returned by Records() { DNSName: "noheritage.test-zone.example.org", Targets: endpoint.Targets{"random"}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ // No owner because it's not external-dns heritage endpoint.OwnerLabelKey: "", }, }, { DNSName: "oldformat-otherowner.test-zone.example.org", Targets: endpoint.Targets{"bar.loadbalancer.com"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ // Records() retrieves all the records of the zone, no matter the owner endpoint.OwnerLabelKey: "otherowner", }, }, { DNSName: "unmanaged1.test-zone.example.org", Targets: endpoint.Targets{"unmanaged1.loadbalancer.com"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "unmanaged2.test-zone.example.org", Targets: endpoint.Targets{"unmanaged2.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "this-is-a-63-characters-long-label-that-we-do-expect-will-work.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, } r, _ := newRegistry(p, "", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS}, []string{}, false, nil, "") records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) } func testTXTRegistryMissingRecordsWithPrefix(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("oldformat.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.oldformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("oldformat2.test-zone.example.org", "bar.loadbalancer.com", endpoint.RecordTypeA, ""), newEndpointWithOwner("txt.oldformat2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("newformat.test-zone.example.org", "foobar.nameserver.com", endpoint.RecordTypeNS, ""), newEndpointWithOwner("txt.ns-newformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("oldformat3.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.oldformat3.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.newformat.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("noheritage.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("oldformat-otherowner.test-zone.example.org", "bar.loadbalancer.com", endpoint.RecordTypeA, ""), newEndpointWithOwner("txt.oldformat-otherowner.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=otherowner\"", endpoint.RecordTypeTXT, ""), endpoint.NewEndpoint("unmanaged1.test-zone.example.org", endpoint.RecordTypeA, "unmanaged1.loadbalancer.com"), endpoint.NewEndpoint("unmanaged2.test-zone.example.org", endpoint.RecordTypeCNAME, "unmanaged2.loadbalancer.com"), }, }) require.NoError(t, err) expectedRecords := []*endpoint.Endpoint{ { DNSName: "oldformat.test-zone.example.org", Targets: endpoint.Targets{"foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{ // owner was added from the TXT record's target endpoint.OwnerLabelKey: "owner", }, ProviderSpecific: []endpoint.ProviderSpecificProperty{ { Name: "txt/force-update", Value: "true", }, }, }, { DNSName: "oldformat2.test-zone.example.org", Targets: endpoint.Targets{"bar.loadbalancer.com"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, ProviderSpecific: []endpoint.ProviderSpecificProperty{ { Name: "txt/force-update", Value: "true", }, }, }, { DNSName: "oldformat3.test-zone.example.org", Targets: endpoint.Targets{"random"}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, ProviderSpecific: []endpoint.ProviderSpecificProperty{ { Name: "txt/force-update", Value: "true", }, }, }, { DNSName: "newformat.test-zone.example.org", Targets: endpoint.Targets{"foobar.nameserver.com"}, RecordType: endpoint.RecordTypeNS, Labels: map[string]string{ endpoint.OwnerLabelKey: "owner", }, }, { DNSName: "noheritage.test-zone.example.org", Targets: endpoint.Targets{"random"}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ // No owner because it's not external-dns heritage endpoint.OwnerLabelKey: "", }, }, { DNSName: "oldformat-otherowner.test-zone.example.org", Targets: endpoint.Targets{"bar.loadbalancer.com"}, RecordType: endpoint.RecordTypeA, Labels: map[string]string{ // All the records of the zone are retrieved, no matter the owner endpoint.OwnerLabelKey: "otherowner", }, }, { DNSName: "unmanaged1.test-zone.example.org", Targets: endpoint.Targets{"unmanaged1.loadbalancer.com"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "unmanaged2.test-zone.example.org", Targets: endpoint.Targets{"unmanaged2.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, }, } r, _ := newRegistry(p, "txt.", "", "owner", time.Hour, "wc", []string{endpoint.RecordTypeCNAME, endpoint.RecordTypeA, endpoint.RecordTypeNS, endpoint.RecordTypeTXT}, []string{}, false, nil, "") records, _ := r.Records(ctx) assert.True(t, testutils.SameEndpoints(records, expectedRecords)) } func TestCacheMethods(t *testing.T) { cache := []*endpoint.Endpoint{ newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), } registry := &TXTRegistry{ recordsCache: cache, cacheInterval: time.Hour, } expectedCacheAfterAdd := []*endpoint.Endpoint{ newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing4.com", "2001:DB8::1", "AAAA", "owner"), newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), } expectedCacheAfterUpdate := []*endpoint.Endpoint{ newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2"), newEndpointWithOwner("thing4.com", "2001:DB8::2", "AAAA", "owner"), } expectedCacheAfterDelete := []*endpoint.Endpoint{ newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), } // test add cache registry.addToCache(newEndpointWithOwner("thing4.com", "2001:DB8::1", "AAAA", "owner")) registry.addToCache(newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner")) if !reflect.DeepEqual(expectedCacheAfterAdd, registry.recordsCache) { t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterAdd, registry.recordsCache) } // test update cache registry.removeFromCache(newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner")) registry.addToCache(newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2")) registry.removeFromCache(newEndpointWithOwner("thing4.com", "2001:DB8::1", "AAAA", "owner")) registry.addToCache(newEndpointWithOwner("thing4.com", "2001:DB8::2", "AAAA", "owner")) // ensure it was updated if !reflect.DeepEqual(expectedCacheAfterUpdate, registry.recordsCache) { t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterUpdate, registry.recordsCache) } // test deleting a record registry.removeFromCache(newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2")) registry.removeFromCache(newEndpointWithOwner("thing4.com", "2001:DB8::2", "AAAA", "owner")) // ensure it was deleted if !reflect.DeepEqual(expectedCacheAfterDelete, registry.recordsCache) { t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterDelete, registry.recordsCache) } } func TestNewTXTScheme(t *testing.T) { p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) var ctxEndpoints []*endpoint.Endpoint ctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints) p.OnApplyChanges = func(ctx context.Context, _ *plan.Changes) { assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) } err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("txt.bar.test-zone.example.org", "baz.test-zone.example.org", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("qux.test-zone.example.org", "random", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) require.NoError(t, err) r, err := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") require.NoError(t, err) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), }, UpdateNew: []*endpoint.Endpoint{ newEndpointWithOwner("tar.test-zone.example.org", "new-tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), }, UpdateOld: []*endpoint.Endpoint{ newEndpointWithOwner("tar.test-zone.example.org", "tar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner-2"), }, } expected := &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("cname-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "new-record-1.test-zone.example.org"), newEndpointWithOwner("example", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("cname-example", "\"heritage=external-dns,external-dns/owner=owner\"", "example"), }, Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("cname-foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", "foobar.test-zone.example.org"), }, UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, } p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, "UpdateOld": expected.UpdateOld, "Delete": expected.Delete, } mGot := map[string][]*endpoint.Endpoint{ "Create": got.Create, "UpdateNew": got.UpdateNew, "UpdateOld": got.UpdateOld, "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) assert.Nil(t, ctx.Value(provider.RecordsContextKey)) } err = r.ApplyChanges(ctx, changes) require.NoError(t, err) } func TestGenerateTXT(t *testing.T) { record := newEndpointWithOwner("foo.test-zone.example.org", "new-foo.loadbalancer.com", endpoint.RecordTypeCNAME, "owner") expectedTXT := []*endpoint.Endpoint{ { DNSName: "cname-foo.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=owner\""}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ endpoint.OwnedRecordLabelKey: "foo.test-zone.example.org", }, }, } p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") gotTXT := r.generateTXTRecord(record) assert.Equal(t, expectedTXT, gotTXT) } func TestGenerateTXTWithMigration(t *testing.T) { record := newEndpointWithOwner("foo.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, "owner") expectedTXTBeforeMigration := []*endpoint.Endpoint{ { DNSName: "a-foo.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=owner\""}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ endpoint.OwnedRecordLabelKey: "foo.test-zone.example.org", }, }, } p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") gotTXTBeforeMigration := r.generateTXTRecord(record) assert.Equal(t, expectedTXTBeforeMigration, gotTXTBeforeMigration) expectedTXTAfterMigration := []*endpoint.Endpoint{ { DNSName: "a-foo.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=foobar\""}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ endpoint.OwnedRecordLabelKey: "foo.test-zone.example.org", }, }, } rMigrated, _ := newRegistry(p, "", "", "foobar", time.Hour, "", []string{}, []string{}, false, nil, "owner") gotTXTAfterMigration := rMigrated.generateTXTRecord(record) assert.Equal(t, expectedTXTAfterMigration, gotTXTAfterMigration) } func TestGenerateTXTForAAAA(t *testing.T) { record := newEndpointWithOwner("foo.test-zone.example.org", "2001:DB8::1", endpoint.RecordTypeAAAA, "owner") expectedTXT := []*endpoint.Endpoint{ { DNSName: "aaaa-foo.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=owner\""}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{ endpoint.OwnedRecordLabelKey: "foo.test-zone.example.org", }, }, } p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") gotTXT := r.generateTXTRecord(record) assert.Equal(t, expectedTXT, gotTXT) } func TestFailGenerateTXT(t *testing.T) { cnameRecord := &endpoint.Endpoint{ DNSName: "foo-some-really-big-name-not-supported-and-will-fail-000000000000000000.test-zone.example.org", Targets: endpoint.Targets{"new-foo.loadbalancer.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: map[string]string{}, } // A bad DNS name returns empty expected TXT expectedTXT := make([]*endpoint.Endpoint, 0) p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") gotTXT := r.generateTXTRecord(cnameRecord) assert.Equal(t, expectedTXT, gotTXT) } func TestTXTRegistryApplyChangesEncrypt(t *testing.T) { p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) var ctxEndpoints []*endpoint.Endpoint ctx := context.WithValue(t.Context(), provider.RecordsContextKey, ctxEndpoints) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newTXTEndpointWithOwnedRecord("txt.cname-foobar.test-zone.example.org", "\"h8UQ6jelUFUsEIn7SbFktc2MYXPx/q8lySqI4VwfVtVaIbb2nkHWV/88KKbuLtu7fJNzMir8ELVeVnRSY01KdiIuj7ledqZe5ailEjQaU5Z6uEKd5pgs6sH8\"", "foobar.test-zone.example.org"), }, }) require.NoError(t, err) r, _ := newRegistry(p, "txt.", "", "owner", time.Hour, "", []string{}, []string{}, true, []byte("12345678901234567890123456789012"), "") records, _ := r.Records(ctx) changes := &plan.Changes{ Delete: records, } // ensure that encryption nonce gets reused when deleting records expected := &plan.Changes{ Delete: []*endpoint.Endpoint{ newEndpointWithOwner("foobar.test-zone.example.org", "foobar.loadbalancer.com", endpoint.RecordTypeCNAME, "owner"), newTXTEndpointWithOwnedRecord("txt.cname-foobar.test-zone.example.org", "\"h8UQ6jelUFUsEIn7SbFktc2MYXPx/q8lySqI4VwfVtVaIbb2nkHWV/88KKbuLtu7fJNzMir8ELVeVnRSY01KdiIuj7ledqZe5ailEjQaU5Z6uEKd5pgs6sH8\"", "foobar.test-zone.example.org"), }, } p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Delete": expected.Delete, } mGot := map[string][]*endpoint.Endpoint{ "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) assert.Nil(t, ctx.Value(provider.RecordsContextKey)) } err = r.ApplyChanges(ctx, changes) require.NoError(t, err) } // TestMultiClusterDifferentRecordTypeOwnership validates the registry handles environments where the same zone is managed by // external-dns in different clusters and the ingress record type is different. For example one uses A records and the other // uses CNAME. In this environment the first cluster that establishes the owner record should maintain ownership even // if the same ingress host is deployed to the other. With the introduction of Dual Record support each record type // was treated independently and would cause each cluster to fight over ownership. This tests ensure that the default // Dual Stack record support only treats AAAA records independently and while keeping A and CNAME record ownership intact. func TestMultiClusterDifferentRecordTypeOwnership(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ // records on cluster using A record for ingress address newEndpointWithOwner("bar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=cat,external-dns/resource=ingress/default/foo\"", endpoint.RecordTypeTXT, ""), newEndpointWithOwner("bar.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, ""), }, }) require.NoError(t, err) r, _ := newRegistry(p, "_owner.", "", "bar", time.Hour, "", []string{}, []string{}, false, nil, "") records, _ := r.Records(ctx) // new cluster has same ingress host as other cluster and uses CNAME ingress address cname := &endpoint.Endpoint{ DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{"cluster-b"}, RecordType: "CNAME", Labels: map[string]string{ endpoint.ResourceLabelKey: "ingress/default/foo-127", }, } desired := []*endpoint.Endpoint{cname} pl := &plan.Plan{ Policies: []plan.Policy{&plan.SyncPolicy{}}, Current: records, Desired: desired, ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME}, } changes := pl.Calculate() p.OnApplyChanges = func(_ context.Context, changes *plan.Changes) { got := map[string][]*endpoint.Endpoint{ "Create": changes.Create, "UpdateNew": changes.UpdateNew, "UpdateOld": changes.UpdateOld, "Delete": changes.Delete, } expected := map[string][]*endpoint.Endpoint{ "Create": {}, "UpdateNew": {}, "UpdateOld": {}, "Delete": {}, } testutils.SamePlanChanges(got, expected) } err = r.ApplyChanges(ctx, changes.Changes) require.NoError(t, err) } func TestGenerateTXTRecordWithNewFormatOnly(t *testing.T) { p := inmemory.NewInMemoryProvider() testCases := []struct { name string endpoint *endpoint.Endpoint expectedRecords int expectedPrefix string description string }{ { name: "legacy format enabled - standard record", endpoint: newEndpointWithOwner("foo.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, "owner"), expectedRecords: 1, expectedPrefix: "a-", description: "Should generate only new format TXT records", }, { name: "new format only - standard record", endpoint: newEndpointWithOwner("foo.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, "owner"), expectedRecords: 1, expectedPrefix: "a-", description: "Should only generate new format TXT record", }, { name: "legacy format enabled - AAAA record", endpoint: newEndpointWithOwner("foo.test-zone.example.org", "2001:db8::1", endpoint.RecordTypeAAAA, "owner"), expectedRecords: 1, expectedPrefix: "aaaa-", description: "Should only generate new format for AAAA records regardless of setting", }, { name: "new format only - AAAA record", endpoint: newEndpointWithOwner("foo.test-zone.example.org", "2001:db8::1", endpoint.RecordTypeAAAA, "owner"), expectedRecords: 1, expectedPrefix: "aaaa-", description: "Should only generate new format for AAAA records", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") records := r.generateTXTRecord(tc.endpoint) assert.Len(t, records, tc.expectedRecords, tc.description) for _, record := range records { assert.Equal(t, endpoint.RecordTypeTXT, record.RecordType) } if tc.endpoint.RecordType == endpoint.RecordTypeAAAA { hasNewFormat := false for _, record := range records { if strings.HasPrefix(record.DNSName, tc.expectedPrefix) { hasNewFormat = true break } } assert.True(t, hasNewFormat, "Should have at least one record with prefix %s when using new format", tc.expectedPrefix) } }) } } func TestApplyChangesWithNewFormatOnly(t *testing.T) { p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) ctx := t.Context() r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") changes := &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("new-record.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, "owner"), }, } err = r.ApplyChanges(ctx, changes) require.NoError(t, err) records, err := p.Records(ctx) require.NoError(t, err) var txtRecords []*endpoint.Endpoint for _, record := range records { if record.RecordType == endpoint.RecordTypeTXT { txtRecords = append(txtRecords, record) } } assert.Len(t, txtRecords, 1, "Should only create one TXT record in new format") if len(txtRecords) > 0 { assert.True(t, strings.HasPrefix(txtRecords[0].DNSName, "a-"), "TXT record should have 'a-' prefix when using new format only") } } func TestTXTRegistryRecordsWithEmptyTargets(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "empty-targets.test-zone.example.org", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{}, }, { DNSName: "valid-targets.test-zone.example.org", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"target1"}, }, }, }) require.NoError(t, err) r, _ := newRegistry(p, "", "", "owner", time.Hour, "", []string{}, []string{}, false, nil, "") hook := logtest.LogsUnderTestWithLogLevel(log.ErrorLevel, t) records, err := r.Records(ctx) require.NoError(t, err) expectedRecords := []*endpoint.Endpoint{ { DNSName: "valid-targets.test-zone.example.org", Targets: endpoint.Targets{"target1"}, RecordType: endpoint.RecordTypeTXT, Labels: map[string]string{}, }, } assert.True(t, testutils.SameEndpoints(records, expectedRecords)) logtest.TestHelperLogContains("TXT record has no targets empty-targets.test-zone.example.org", hook, t) } // TestTXTRegistryRecreatesMissingRecords reproduces issue #4914. // It verifies that External‑DNS recreates A/CNAME records that were accidentally deleted while their corresponding TXT records remain. // An InMemoryProvider is used because, like Route53, it throws an error when attempting to create a duplicate record. func TestTXTRegistryRecreatesMissingRecords(t *testing.T) { ownerId := "owner" tests := []struct { name string desired []*endpoint.Endpoint existing []*endpoint.Endpoint expectedCreate []*endpoint.Endpoint }{ { name: "Recreate missing A record when TXT exists", desired: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), }, existing: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("a-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), }, expectedCreate: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ownerId), }, }, { name: "Recreate missing AAAA record when TXT exists", desired: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "2001:db8::1", endpoint.RecordTypeAAAA, ""), }, existing: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("aaaa-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), }, expectedCreate: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "2001:db8::1", endpoint.RecordTypeAAAA, ownerId), }, }, { name: "Recreate missing CNAME record when TXT exists", desired: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), }, existing: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("cname-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), }, expectedCreate: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ownerId)}, }, { name: "Recreate missing A and CNAME records when TXT exists", desired: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), newEndpointWithOwner("new-record-2.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), }, existing: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("new-record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("a-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("cname-new-record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), }, expectedCreate: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ownerId), newEndpointWithOwner("new-record-2.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ownerId), }, }, { name: "Recreate missing A records when TXT and CNAME exists", desired: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), newEndpointWithOwner("new-record-2.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ""), }, existing: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-2.test-zone.example.org", "new-loadbalancer-1.lb.com", endpoint.RecordTypeCNAME, ownerId), newEndpointWithOwner("new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("new-record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("a-new-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("cname-new-record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), }, expectedCreate: []*endpoint.Endpoint{ newEndpointWithOwner("new-record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ownerId), }, }, { name: "Only one A record is missing among several existing records", desired: []*endpoint.Endpoint{ newEndpointWithOwner("record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), newEndpointWithOwner("record-2.test-zone.example.org", "1.1.1.2", endpoint.RecordTypeA, ""), newEndpointWithOwner("record-3.test-zone.example.org", "1.1.1.3", endpoint.RecordTypeA, ""), newEndpointWithOwner("record-4.test-zone.example.org", "2001:db8::4", endpoint.RecordTypeAAAA, ""), newEndpointWithOwner("record-5.test-zone.example.org", "cluster-b", endpoint.RecordTypeCNAME, ""), }, existing: []*endpoint.Endpoint{ newEndpointWithOwner("record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("a-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("record-2.test-zone.example.org", "1.1.1.2", endpoint.RecordTypeA, ownerId), newEndpointWithOwner("record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("a-record-2.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("record-3.test-zone.example.org", "1.1.1.3", endpoint.RecordTypeA, ownerId), newEndpointWithOwner("record-3.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("a-record-3.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("record-4.test-zone.example.org", "2001:db8::4", endpoint.RecordTypeAAAA, ownerId), newEndpointWithOwner("record-4.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("aaaa-record-4.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("record-5.test-zone.example.org", "cluster-b", endpoint.RecordTypeCNAME, ownerId), newEndpointWithOwner("record-5.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), newEndpointWithOwner("cname-record-5.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+ownerId+"\"", endpoint.RecordTypeTXT, ownerId), }, expectedCreate: []*endpoint.Endpoint{ newEndpointWithOwner("record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ownerId), }, }, { name: "Should not recreate TXT records for existing A records without owner", desired: []*endpoint.Endpoint{ newEndpointWithOwner("record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), }, existing: []*endpoint.Endpoint{ newEndpointWithOwner("record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ownerId), // Missing TXT record for the existing A record }, expectedCreate: []*endpoint.Endpoint{}, }, { name: "Should not recreate TXT records for existing A records with another owner", desired: []*endpoint.Endpoint{ newEndpointWithOwner("record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, ""), }, existing: []*endpoint.Endpoint{ // This test uses the `ownerId` variable, and "another-owner" simulates a different owner. // In this case, TXT records should not be recreated. newEndpointWithOwner("record-1.test-zone.example.org", "1.1.1.1", endpoint.RecordTypeA, "another-owner"), newEndpointWithOwner("a-record-1.test-zone.example.org", "\"heritage=external-dns,external-dns/owner="+"another-owner"+"\"", endpoint.RecordTypeTXT, "another-owner"), }, expectedCreate: []*endpoint.Endpoint{}, }, } for _, tt := range tests { for _, setIdentifier := range []string{"", "set-identifier"} { for pName, policy := range plan.Policies { // Clone inputs per policy to avoid data races when using t.Parallel. desired := cloneEndpointsWithOpts(tt.desired, func(e *endpoint.Endpoint) { e.WithSetIdentifier(setIdentifier) }) existing := cloneEndpointsWithOpts(tt.existing, func(e *endpoint.Endpoint) { e.WithSetIdentifier(setIdentifier) }) expectedCreate := cloneEndpointsWithOpts(tt.expectedCreate, func(e *endpoint.Endpoint) { e.WithSetIdentifier(setIdentifier) }) t.Run(fmt.Sprintf("%s with %s policy and setIdentifier=%s", tt.name, pName, setIdentifier), func(t *testing.T) { t.Parallel() ctx := t.Context() p := inmemory.NewInMemoryProvider() // Given: Register existing records err := p.CreateZone(testZone) require.NoError(t, err) err = p.ApplyChanges(ctx, &plan.Changes{Create: existing}) assert.NoError(t, err) // The first ApplyChanges call should create the expected records. // Subsequent calls are expected to be no-ops (i.e., no additional creates). isCalled := false p.OnApplyChanges = func(_ context.Context, changes *plan.Changes) { if isCalled { assert.Empty(t, changes.Create, "ApplyChanges should not be called multiple times with new changes") } else { assert.True(t, testutils.SameEndpoints(changes.Create, expectedCreate), "Expected create changes: %v, but got: %v", expectedCreate, changes.Create, ) } assert.Empty(t, changes.UpdateNew, "UpdateNew should be empty") assert.Empty(t, changes.UpdateOld, "UpdateOld should be empty") assert.Empty(t, changes.Delete, "Delete should be empty") isCalled = true } // When: Apply changes to recreate missing A records managedRecords := []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeAAAA, endpoint.RecordTypeTXT} registry, err := newRegistry(p, "", "", ownerId, time.Hour, "", managedRecords, nil, false, nil, "") assert.NoError(t, err) expectedRecords := append(existing, expectedCreate...) // nolint:gocritic // Simulate the reconciliation loop by executing multiple times reconciliationLoops := 3 for i := range reconciliationLoops { records, err := registry.Records(ctx) assert.NoError(t, err) pl := &plan.Plan{ Policies: []plan.Policy{policy}, Current: records, Desired: desired, ManagedRecords: managedRecords, OwnerID: ownerId, } pln := pl.Calculate() err = registry.ApplyChanges(ctx, pln.Changes) assert.NoError(t, err) // Then: Verify that the missing records are recreated or the existing records are not modified records, err = p.Records(ctx) assert.NoError(t, err) assert.True(t, testutils.SameEndpoints(records, expectedRecords), "Expected records after reconciliation loop #%d: %v, but got: %v", i, expectedRecords, records, ) } }) } } } } func TestTXTRecordMigration(t *testing.T) { ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) r, _ := newRegistry(p, "%{record_type}-", "", "foo", time.Hour, "", []string{}, []string{}, false, nil, "") err = r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ // records on cluster using A record for ingress address newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, "foo", endpoint.Labels{endpoint.OwnerLabelKey: "owner"}), }, }) require.NoError(t, err) createdRecords, _ := r.Records(ctx) newTXTRecord := r.generateTXTRecord(createdRecords[0]) expectedTXTRecords := []*endpoint.Endpoint{ { DNSName: "a-bar.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=foo\""}, RecordType: endpoint.RecordTypeTXT, }, } assert.Equal(t, expectedTXTRecords[0].Targets, newTXTRecord[0].Targets) r, _ = newRegistry(p, "%{record_type}-", "", "foobar", time.Hour, "", []string{}, []string{}, false, nil, "foo") updatedRecords, _ := r.Records(ctx) updatedTXTRecord := r.generateTXTRecord(updatedRecords[0]) expectedFinalTXT := []*endpoint.Endpoint{ { DNSName: "a-bar.test-zone.example.org", Targets: endpoint.Targets{"\"heritage=external-dns,external-dns/owner=foobar\""}, RecordType: endpoint.RecordTypeTXT, }, } assert.Equal(t, updatedTXTRecord[0].Targets, expectedFinalTXT[0].Targets) } // TestRecreateRecordAfterDeletion ensures that when A and TXT records are deleted, // both are correctly recreated in subsequent reconciliation loops. // This prevents regression of the issue where stale TXT record state // caused ExternalDNS to skip recreating TXT records after deletion. func TestRecreateRecordAfterDeletion(t *testing.T) { ownerID := "foo" ctx := t.Context() p := inmemory.NewInMemoryProvider() err := p.CreateZone(testZone) require.NoError(t, err) r, _ := newRegistry(p, "%{record_type}-", "", "foo", 0, "", []string{endpoint.RecordTypeA}, []string{}, false, nil, "") createdRecords := newEndpointWithOwnerAndLabels("bar.test-zone.example.org", "1.2.3.4", endpoint.RecordTypeA, ownerID, nil) txtRecord := r.generateTXTRecord(createdRecords) // 1. Create initial A and TXT records. creates := append([]*endpoint.Endpoint{createdRecords}, txtRecord...) err = p.ApplyChanges(ctx, &plan.Changes{ Create: creates, }) assert.NoError(t, err) // 2. Simulate a "no change" reconciliation (ApplyChanges won't be called). desired := []*endpoint.Endpoint{ { DNSName: "bar.test-zone.example.org", Targets: endpoint.Targets{ "1.2.3.4", }, RecordType: endpoint.RecordTypeA, }, } records, err := r.Records(ctx) assert.NoError(t, err) calculated := &plan.Plan{ Policies: []plan.Policy{&plan.SyncPolicy{}}, ManagedRecords: []string{endpoint.RecordTypeA}, Current: records, Desired: desired, OwnerID: ownerID, } calculated = calculated.Calculate() // ApplyChanges is not called to simulate no changes. assert.False(t, calculated.Changes.HasChanges(), "There should be no changes") // 3. Delete both A and TXT records (simulate manual deletion) deletes := append([]*endpoint.Endpoint{createdRecords}, txtRecord...) err = p.ApplyChanges(ctx, &plan.Changes{ Delete: deletes, }) assert.NoError(t, err) // 4. Run reconciliation again — both A and TXT should be recreated. records, err = r.Records(ctx) assert.NoError(t, err) calculated = &plan.Plan{ Policies: []plan.Policy{&plan.SyncPolicy{}}, ManagedRecords: []string{endpoint.RecordTypeA}, Current: records, Desired: desired, OwnerID: ownerID, } calculated = calculated.Calculate() if !calculated.Changes.HasChanges() { assert.Fail(t, "There should be changes") } err = r.ApplyChanges(ctx, calculated.Changes) assert.NoError(t, err) // 5. Verify that both A and TXT records are recreated successfully. records, err = p.Records(ctx) assert.NoError(t, err) assert.True(t, testutils.SameEndpoints(records, append(desired, txtRecord...)), "Expected records after reconciliation: %v, but got: %v", append(desired, txtRecord...), records) } ================================================ FILE: registry/txt/utils_test.go ================================================ /* 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 txt import ( "maps" "sigs.k8s.io/external-dns/endpoint" ) func newEndpointWithOwner(dnsName, target, recordType, ownerID string) *endpoint.Endpoint { return newEndpointWithOwnerAndLabels(dnsName, target, recordType, ownerID, nil) } func newMultiTargetEndpointWithOwner(dnsName string, targets endpoint.Targets, recordType, ownerID string) *endpoint.Endpoint { return newMultiTargetEndpointWithOwnerAndLabels(dnsName, targets, recordType, ownerID, nil) } func newTXTEndpointWithOwnedRecord(dnsName, target, ownedRecord string) *endpoint.Endpoint { return newEndpointWithOwnerAndLabels(dnsName, target, endpoint.RecordTypeTXT, "", endpoint.Labels{endpoint.OwnedRecordLabelKey: ownedRecord}) } func newMultiTargetEndpointWithOwnerAndLabels(dnsName string, targets endpoint.Targets, recordType, ownerID string, labels endpoint.Labels) *endpoint.Endpoint { e := endpoint.NewEndpoint(dnsName, recordType, targets...) e.Labels[endpoint.OwnerLabelKey] = ownerID maps.Copy(e.Labels, labels) return e } func newEndpointWithOwnerAndLabels(dnsName, target, recordType, ownerID string, labels endpoint.Labels) *endpoint.Endpoint { e := endpoint.NewEndpoint(dnsName, recordType, target) e.Labels[endpoint.OwnerLabelKey] = ownerID maps.Copy(e.Labels, labels) return e } func newCNAMEEndpointWithOwnerResource(dnsName, target, ownerID, resource string) *endpoint.Endpoint { e := endpoint.NewEndpoint(dnsName, endpoint.RecordTypeCNAME, target) e.Labels[endpoint.OwnerLabelKey] = ownerID e.Labels[endpoint.ResourceLabelKey] = resource return e } // This is primarily used to prevent data races when running tests in parallel (t.Parallel). func cloneEndpointsWithOpts(list []*endpoint.Endpoint, opt ...func(*endpoint.Endpoint)) []*endpoint.Endpoint { cloned := make([]*endpoint.Endpoint, len(list)) for i, e := range list { cloned[i] = cloneEndpointWithOpts(e, opt...) } return cloned } func cloneEndpointWithOpts(e *endpoint.Endpoint, opt ...func(*endpoint.Endpoint)) *endpoint.Endpoint { targets := make(endpoint.Targets, len(e.Targets)) copy(targets, e.Targets) // SameEndpoints treats nil and empty maps/slices as different. // To avoid introducing unintended differences, we retain nil when original is nil. var labels endpoint.Labels if e.Labels != nil { labels = make(endpoint.Labels, len(e.Labels)) maps.Copy(labels, e.Labels) } var providerSpecific endpoint.ProviderSpecific if e.ProviderSpecific != nil { providerSpecific = make(endpoint.ProviderSpecific, len(e.ProviderSpecific)) for i, p := range e.ProviderSpecific { providerSpecific[i] = p } } ttl := e.RecordTTL ep := &endpoint.Endpoint{ DNSName: e.DNSName, Targets: targets, RecordType: e.RecordType, RecordTTL: ttl, Labels: labels, ProviderSpecific: providerSpecific, SetIdentifier: e.SetIdentifier, } for _, o := range opt { o(ep) } return ep } ================================================ FILE: scripts/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - scripts ================================================ FILE: scripts/aws-cleanup-legacy-txt-records.py ================================================ #!/usr/bin/env python # 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. # Warning: The script deletes all records that match certain values. It could delete both legacy and new records if there is no way to differentiate them. # This Python script is designed to help migrate DNS management to `external-dns` by cleaning up legacy TXT records in AWS Route 53. # It identifies and deletes TXT records that match a specified pattern, ensuring that `external-dns` can take over managing these resources. # The script performs the following steps: # # 1. **Setup and Configuration**: # - Imports necessary libraries (`boto3`, `argparse`, etc.). # - Defines constants and utility functions. # - Parses command-line arguments for configuration. # # 2. **Record Class**: # - Represents a DNS record with methods to check if it should be deleted. # # 3. **Main Functionality**: # - Connects to AWS Route 53 using `boto3`. # - Support single zone cleanup at a time. # - Lists and filters TXT records based on the specified pattern. # - Deletes the filtered records in batches, with an option for a dry run or actual deletion. # # 4. **Execution**: # - The script is executed with command-line arguments specifying the hosted zone ID, record pattern, total items to delete, batch size, and whether to perform a dry run or actual deletion. # - Check 'To Run script' section for more details # WARNING: run this script at your own RISK. This will delete all the TXT records that do contain certain string. # To Run script # 1. Python, pip and pipenv installed https://pipenv.pypa.io/en/latest/ # 2. AWS Access https://docs.aws.amazon.com/signin/latest/userguide/command-line-sign-in.html # 3. pipenv shell # 4. pip install boto3 # 5. python scripts/aws-cleanup-legacy-txt-records.py --help # 6. DRY RUN python scripts/aws-cleanup-legacy-txt-records.py --zone-id ASDFQEQREWRQADF --record-match text # 6.1 Before execution consider to stop `external-dns` # 7. Execute Deletion. First few times with reduced number of items # - python scripts/aws-cleanup-legacy-txt-records.py --zone-id ASDFQEQREWRQADF --total-items 3 --batch-delete-count 1 --record-match 'external-dns' # - python scripts/aws-cleanup-legacy-txt-records.py --zone-id ASDFQEQREWRQADF --total-items 10000 --batch-delete-count 50 --run --record-match "external-dns/owner=default" # python scripts/aws-cleanup-legacy-txt-records.py --help # python scripts/aws-cleanup-legacy-txt-records.py --zone-id Z06155043AVN8RVC88TYY --total-items 300 --batch-delete-count 20 --record-match "external-dns/owner=default" --run import boto3 from botocore.config import Config as AwsConfig import json, argparse, os, uuid, time MAX_ITEMS=300 # max is 300 https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/route53/client/list_resource_record_sets.html SLEEP=1 # in seconds, required to make sure Route53 API is not throttled SESSION_ID=uuid.uuid4() def json_prettify(data): return json.dumps(data, indent=4, default=str) class Record: def __init__(self, record): # static self.type = 'TXT' self.record = record self.name = record['Name'] self.resource_records = record['ResourceRecords'] resource_record = '' for r in self.resource_records: resource_record += r['Value'] self.resource_record = resource_record def is_for_deletion(self, contains): if contains in self.resource_record: return True return False def __str__(self): return f'record: name: {self.name}, type: {self.type}, records: {self.resource_record}' class Config: def __init__(self, zone_id, contain, total_items, batch, run): self.zone_id = zone_id self.record_contain = contain self.total_items = total_items self.batch_size = batch self.run = run self.contain = contain def records(config: Config) -> None: print(f"calculate TXT records to cleanup for 'zone:{config.zone_id}' and 'max records:{config.total_items}'") # https://botocore.amazonaws.com/v1/documentation/api/latest/reference/config.html cfg = AwsConfig( user_agent=f"ExternalDNS/boto3-{SESSION_ID}", ) r53client = boto3.client('route53', config=cfg) dns_records_to_cleanup = [] items = 0 try: params = { 'HostedZoneId': config.zone_id, 'MaxItems': str(MAX_ITEMS), } dns_in_iteration = r53client.list_resource_record_sets(**params) elements = dns_in_iteration['ResourceRecordSets'] for el in elements: if el['Type'] == 'TXT': record = Record(el) if record.is_for_deletion(config.contain): dns_records_to_cleanup.append(record) print("to cleanup >>", record) items += 1 if items >= config.total_items: break while len(elements) > 0 and 'NextRecordName' in dns_in_iteration.keys() and items < config.total_items: dns_in_iteration = r53client.list_resource_record_sets( HostedZoneId= config.zone_id, StartRecordName= dns_in_iteration['NextRecordName'], MaxItems= str(MAX_ITEMS), ) elements = dns_in_iteration['ResourceRecordSets'] for el in elements: if el['Type'] == 'TXT': record = Record(el) if record.is_for_deletion(config.contain): dns_records_to_cleanup.append(record) print("to cleanup >>", record) items += 1 if items >= config.total_items: break if len(dns_records_to_cleanup) > 0: delete_records(r53client, config, dns_records_to_cleanup) else: print("No 'TXT' records found to cleanup....") except Exception as e: print(f"An error occurred: {e}") os._exit(os.EX_OSERR) def delete_records(client: boto3.client, config: Config, records: list[Record]) -> None: total=len(records) print(f"will cleanup '{total}' records with batch '{config.batch_size}' at a time") count = 0 if config.run: print("deletion of records!!") else: print("dry run execution") for i in range(0, total, config.batch_size): if config.batch_size <= 0: break batch = records[i:min(i + config.batch_size, total)] count += config.batch_size if count >= total: count = total changes = [] for el in batch: # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/route53/client/change_resource_record_sets.html changes.append({ 'Action': 'DELETE', 'ResourceRecordSet': el.record }) print(f"BATCH deletion(start). {len(changes)} records > {changes}") if config.run: client.change_resource_record_sets( HostedZoneId=config.zone_id, ChangeBatch={ "Comment": "external-dns legacy record cleanup. batch of ", "Changes": changes, } ) time.sleep(SLEEP) print(f"BATCH deletion(success). {count}/{total}(deleted/total)") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Cleanup legacy TXT records") parser.add_argument("--zone-id", type=str, required=True, help="Hosted Zone ID for which to run a cleanup.") parser.add_argument("--record-match", type=str, required=True, help="Record to match specific value. Example 'external-dns/owner=default'") parser.add_argument("--total-items", type=int, required=False, default=10, help="Number of items to delete. Default to 10") parser.add_argument("--batch-delete-count", type=int, required=False, default=2, help="Number of items to delete in single DELETE batch. Default to 2") parser.add_argument("--run", action="store_true", help="Execute the cleanup. The tool will do a dry-run if --run is not specified.") answer = input("Run this script at your own RISKS!!! Please enter 'yes' or 'no': ") if answer != 'yes': os._exit(0) print(f"Session ID '{SESSION_ID}'") args = parser.parse_args() print("arguments:",args) cfg = Config( zone_id=args.zone_id, contain=args.record_match, total_items=args.total_items, batch=args.batch_delete_count, run=args.run, ) records(cfg) ================================================ FILE: scripts/e2e-test.sh ================================================ #!/bin/bash set -e KO_VERSION="0.18.0" KIND_VERSION="0.30.0" ALPINE_VERSION="3.22" KUBECTL_VERSION="1.35.0" echo "Starting end-to-end tests for external-dns with local provider..." # Install kind echo "Installing kind..." curl -Lo ./kind https://kind.sigs.k8s.io/dl/v${KIND_VERSION}/kind-linux-amd64 chmod +x ./kind sudo mv ./kind /usr/local/bin/kind # Cleanup function cleanup() { echo "Cleaning up..." kind delete cluster 2>/dev/null || true } # Create kind cluster echo "Creating kind cluster..." kind delete cluster 2>/dev/null || true kind create cluster # Set trap to cleanup on script exit trap cleanup EXIT # Install kubectl echo "Installing kubectl..." curl -LO "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/amd64/kubectl" chmod +x kubectl sudo mv kubectl /usr/local/bin/kubectl # Install ko echo "Installing ko..." curl -sSfL "https://github.com/ko-build/ko/releases/download/v${KO_VERSION}/ko_${KO_VERSION}_linux_x86_64.tar.gz" > ko.tar.gz tar xzf ko.tar.gz ko chmod +x ./ko sudo mv ko /usr/local/bin/ko # Build external-dns echo "Building external-dns..." # Use ko with --local to save the image to Docker daemon EXTERNAL_DNS_IMAGE_FULL=$(KO_DOCKER_REPO=ko.local VERSION=$(git describe --tags --always --dirty) \ ko build --tags "$(git describe --tags --always --dirty)" --bare --sbom none \ --platform=linux/amd64 --local .) echo "Built image: $EXTERNAL_DNS_IMAGE_FULL" # Extract image name and tag (strip the @sha256 digest for kind load and kustomize) EXTERNAL_DNS_IMAGE="${EXTERNAL_DNS_IMAGE_FULL%%@*}" echo "Using image reference: $EXTERNAL_DNS_IMAGE" # apply etcd deployment as provider echo "Applying etcd" kubectl apply -f e2e/provider/etcd.yaml # wait for etcd to be ready echo "Waiting for etcd to be ready..." kubectl wait --for=condition=ready --timeout=120s pod -l app=etcd # apply coredns deployment echo "Applying CoreDNS" kubectl apply -f e2e/provider/coredns.yaml # wait for coredns to be ready echo "Waiting for CoreDNS to be ready..." kubectl wait --for=condition=available --timeout=120s deployment/coredns # Build a DNS testing image with dig echo "Building DNS test image with dig..." docker build -t dns-test:v1 -f - . < "$TEMP_KUSTOMIZE_DIR/deployment-args-patch.yaml" apiVersion: apps/v1 kind: Deployment metadata: name: external-dns spec: template: spec: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet containers: - name: external-dns args: - --source=service - --provider=coredns - --txt-owner-id=external.dns - --policy=sync - --log-level=debug env: - name: ETCD_URLS value: http://etcd-0.etcd:2379 EOF # Update kustomization.yaml to include the patch cat < "$TEMP_KUSTOMIZE_DIR/kustomization.yaml" apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: registry.k8s.io/external-dns/external-dns newName: ${EXTERNAL_DNS_IMAGE%%:*} newTag: ${EXTERNAL_DNS_IMAGE##*:} resources: - ./external-dns-deployment.yaml - ./external-dns-serviceaccount.yaml - ./external-dns-clusterrole.yaml - ./external-dns-clusterrolebinding.yaml patchesStrategicMerge: - ./deployment-args-patch.yaml EOF # Apply the kustomization kubectl kustomize "$TEMP_KUSTOMIZE_DIR" | kubectl apply -f - # add a wait for the deployment to be available kubectl wait --for=condition=available --timeout=60s deployment/external-dns || true kubectl describe pods -l app=external-dns kubectl describe deployment external-dns kubectl logs -l app=external-dns # Cleanup temporary directory rm -rf "$TEMP_KUSTOMIZE_DIR" # Apply kubernetes yaml with service echo "Applying Kubernetes service..." kubectl apply -f e2e # Check that the records are present echo "Checking services again..." kubectl get svc -owide kubectl logs -l app=external-dns # Check that the DNS records are present using our DNS server echo "Testing DNS server functionality..." # Get the node IP where the pod is running (since we're using hostNetwork) NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}') echo "Node IP: $NODE_IP" # Test our DNS server with dig, with retry logic echo "Testing DNS server with dig (with retries)..." # Create DNS test job that uses dig to query our DNS server with retries cat </dev/null) if echo "\$RESULT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then echo "DNS query successful: \$RESULT" exit 0 fi echo "DNS query returned empty result, retrying in 10s..." sleep 10 ATTEMPT=\$((ATTEMPT + 1)) done echo "DNS query failed after \$MAX_ATTEMPTS attempts" exit 1 EOF # Wait for the job to complete echo "Waiting for DNS server test job to complete..." kubectl wait --for=condition=complete --timeout=240s job/dns-server-test-job || true # Check job status and get results echo "DNS server test job results:" kubectl logs job/dns-server-test-job # Final validation JOB_SUCCEEDED=$(kubectl get job dns-server-test-job -o jsonpath='{.status.succeeded}') if [ "$JOB_SUCCEEDED" = "1" ]; then echo "SUCCESS: DNS server test completed successfully" TEST_PASSED=true else echo "WARNING: DNS server test job did not complete successfully" kubectl describe job dns-server-test-job TEST_PASSED=false fi # Cleanup the test job kubectl delete job dns-server-test-job echo "End-to-end test completed!" if [ "$TEST_PASSED" != "true" ]; then exit 1 fi ================================================ FILE: scripts/generate-crd.sh ================================================ #!/usr/bin/env bash # Copyright 2026 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. # generate-crd.sh # # This script generates Kubernetes Custom Resource Definitions (CRDs) and related # deepcopy code for external-dns using controller-gen from controller-tools. # ## What this script does: # 1. Generates DeepCopy methods for types in the endpoint package # 2. Generates CRD manifests for API types in the apis package # 3. Copies CRDs to the Helm chart directory # # Usage: # ./scripts/generate-crd.sh # make crd # calls this script set -euo pipefail # Get the script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Get the project root (parent of scripts directory) PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "${PROJECT_ROOT}" # Define tool commands (using tools from go.tool.mod) CONTROLLER_GEN="go tool -modfile=go.tool.mod controller-gen" YQ="go tool -modfile=go.tool.mod yq" YAMLFMT="go tool -modfile=go.tool.mod yamlfmt" echo " Generating CRDs using controller-gen..." # Step 1: Generate deepcopy methods for endpoint types # This creates zz_generated.deepcopy.go with DeepCopy/DeepCopyInto/DeepCopyObject methods # The 'object' generator adds these methods for types marked with +kubebuilder:object markers echo " → Generating deepcopy for endpoint package..." ${CONTROLLER_GEN} object crd:crdVersions=v1 paths="./endpoint/..." # Clean up empty import statements from generated files # controller-gen sometimes adds empty import() blocks which create noise in diffs find ./endpoint -name "zz_generated.deepcopy.go" -exec gofmt -s -w {} \; # Step 2: Generate CRD manifests for API types # - Generates CRDs from Go types with kubebuilder markers # - Outputs to stdout, formats with yamlfmt, then splits into individual files # - Each CRD is saved to config/crd/standard/.yaml echo " → Generating CRDs for apis package..." ${CONTROLLER_GEN} object crd:crdVersions=v1 paths="./apis/..." output:crd:stdout | \ ${YAMLFMT} - | \ ${YQ} eval '.' --no-doc --split-exp '"./config/crd/standard/" + .metadata.name + ".yaml"' # Clean up empty import statements from generated files find ./apis -name "zz_generated.deepcopy.go" -exec gofmt -s -w {} \; # Step 3: Copy CRDs to Helm chart with filtered annotations # - Reads CRDs from config/crd/standard/ # - Filters annotations to only keep kubernetes.io/* (removes controller-gen annotations) # - Splits and saves to charts/external-dns/crds/ for Helm chart packaging echo " → Copying CRDs to chart directory..." ${YQ} eval '.metadata.annotations |= with_entries(select(.key | test("kubernetes\.io")))' \ --no-doc --split-exp '"./charts/external-dns/crds/" + .metadata.name + ".yaml"' \ ./config/crd/standard/*.yaml echo -e " ✅ CRD generation complete" ================================================ FILE: scripts/get-sha256.sh ================================================ #!/bin/bash IMAGE=$1 echo -n "image: " crane digest "${IMAGE}" echo "architecture" crane manifest "${IMAGE}" | jq -r '.manifests.[] | .platform.architecture, .digest' ================================================ FILE: scripts/helm-tools.sh ================================================ #!/bin/bash set -e # JSON Schema https://json-schema.org/ # JSON Schema spec https://json-schema.org/draft/2020-12/json-schema-validation # Helm Schema https://helm.sh/docs/topics/charts/#schema-files # Execute # scripts/helm-tools.sh # scripts/helm-tools.sh -h # scripts/helm-tools.sh --install # scripts/helm-tools.sh --diff # scripts/helm-tools.sh --schema # scripts/helm-tools.sh --lint # scripts/helm-tools.sh --docs # scripts/helm-tools.sh --helm-template # scripts/helm-tools.sh --helm-unittest show_help() { cat << EOF 'external-dns' helm linter helper commands Usage: $(basename "$0") -d, --diff Schema diff validation --docs Re-generate helm documentation -h, --help Display help -i, --install Install required tooling -l, --lint Lint chart -s, --schema Generate schema --helm-unittest Run helm unittest(s) --helm-template Run helm template --show-docs Show available documentation EOF } install() { if [[ -x $(which helm) ]]; then echo "installing https://github.com/losisin/helm-values-schema-json.git plugin" helm plugin install https://github.com/losisin/helm-values-schema-json.git --verify=false | true helm plugin update schema helm plugin list | grep "schema" helm plugin install https://github.com/helm-unittest/helm-unittest.git --verify=false | true helm plugin update unittest helm plugin list | grep "unittest" echo "installing helm-docs" go install github.com/norwoodj/helm-docs/cmd/helm-docs@latest | true if [[ -x $(which brew) ]]; then echo "installing chart-testing https://github.com/helm/chart-testing" brew install chart-testing fi else echo "helm is not installed" echo "install helm https://helm.sh/docs/intro/install/ and try again" exit 1 fi } update_schema() { cd charts/external-dns # uses .schema.yamle helm schema } diff_schema() { cd charts/external-dns helm schema \ --output diff-schema.schema.json trap 'rm -rf -- "diff-schema.schema.json"' EXIT CURRENT_SCHEMA=$(cat values.schema.json) GENERATED_SCHEMA=$(cat diff-schema.schema.json) if [ "$CURRENT_SCHEMA" != "$GENERATED_SCHEMA" ]; then echo "Schema must be re-generated! Run 'scripts/helm-tools.sh --schema'" 1>&2 diff -Nau diff-schema.schema.json values.schema.json exit 1 fi } lint_chart() { cd charts/external-dns helm lint . --debug --strict \ --values values.yaml \ --values ci/ci-values.yaml # lint with chart testing tool ct lint --target-branch=master --check-version-increment=false } helm_docs() { cd charts/external-dns helm-docs } helm_unittest() { helm unittest -f 'tests/*_test.yaml' --color charts/external-dns } helm_template() { helm template external-dns charts/external-dns \ --output-dir _scratch \ -n kube-system } show_docs() { open "https://github.com/losisin/helm-values-schema-json?tab=readme-ov-file" } function main() { case $1 in --show-docs) show_docs ;; --helm-unittest) helm_unittest ;; --helm-template) helm_template ;; -d|--diff) diff_schema ;; --docs) helm_docs ;; -i|--install) install ;; -l|--lint) lint_chart ;; -s|--schema) update_schema ;; -h|--help) show_help ;; *) echo "unknown sub-command" >&2 show_help exit 1 ;; esac } main "$@" ================================================ FILE: scripts/install-ko.sh ================================================ #!/usr/bin/env bash # Copyright 2022 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. set -o errexit set -o nounset set -o pipefail if ! command -v ko &> /dev/null; then cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 go install github.com/google/ko@v0.17.1 fi ================================================ FILE: scripts/install-tools.sh ================================================ #!/usr/bin/env bash # 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. # renovate: datasource=github-releases depName=golangci/golangci-lint GOLANG_CI_LINTER_VERSION=v2.7.2 # Execute # scripts/install-tools.sh # scripts/install-tools.sh -h # scripts/install-tools.sh --generator # scripts/install-tools.sh --golangci show_help() { cat << EOF 'external-dns' helm linter helper commands Usage: $(basename "$0") -h, --help Display help --generator Install generator --golangci Install golangci linter EOF } install_golangci() { local install=false if [[ -x $(which golangci-lint) ]]; then local version=$(golangci-lint version --short) if [[ "${version}" == "${GOLANG_CI_LINTER_VERSION#v}" ]]; then install=false else install=true fi else install=true fi if [[ "$install" == true ]]; then curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/9f61b0f53f80672872fced07b6874397c3ed197b/install.sh \ | sh -s -- -b $(go env GOPATH)/bin "${GOLANG_CI_LINTER_VERSION}" fi } function main() { case $1 in --golangci) install_golangci ;; -h|--help) show_help ;; *) echo "unknown sub-command" >&2 show_help exit 1 ;; esac } main "$@" ================================================ FILE: scripts/releaser.sh ================================================ #!/bin/bash set -e function generate_changelog { MERGED_PRS="$1" echo echo "## :warning: Breaking Changes" echo cat "${MERGED_PRS}" | grep "\!" || true # no breaking change, section should be removed. echo echo "## :rocket: Features" echo cat "${MERGED_PRS}" | grep feat[:\(] echo echo "## :bug: Bug fixes" echo cat "${MERGED_PRS}" | grep fix[:\(] echo echo "## :memo: Documentation" echo cat "${MERGED_PRS}" | grep docs[:\(] echo echo "## :package: Others" echo cat "${MERGED_PRS}" | grep -v "\!" | grep -v feat[:\(] | grep -v fix[:\(] | grep -v docs[:\(] echo echo "## :package: Docker Image" echo echo "\`\`\`sh" echo "# This pull command only works when it's released" echo "docker pull registry.k8s.io/external-dns/external-dns:${VERSION}" echo "\`\`\`" } function create_release { generate_changelog | sort # | gh release create "$1" -t "$1" -F - } function latest_release { gh release list -L 10 --json name,isLatest --jq '.[] | select(.isLatest)|.name' } function latest_release_date { gh release list -L 10 --json name,isLatest,publishedAt --jq '.[] | select(.isLatest)|.publishedAt' } function latest_release_ts { gh release list -L 10 --json name,isLatest,publishedAt --jq '.[] | select(.isLatest)|.publishedAt | fromdateiso8601' } if [ $# -ne 1 ]; then echo "** DRY RUN **" fi printf "Latest release: %s (%s)\n" $(latest_release) $(latest_release_date) TIMESTAMP=$(latest_release_ts) MERGED_PRS=$(mktemp) gh pr list \ --state merged \ --json author,number,mergeCommit,mergedAt,url,title \ --limit 999 \ --jq ".[] | select (.mergedAt | fromdateiso8601 > ${TIMESTAMP}) | \ \"- \(.title) by @\(.author.login) in #\(.number)\" " | sort > "${MERGED_PRS}" if [ $# -ne 1 ]; then export VERSION="v0.x.0" generate_changelog "${MERGED_PRS}" echo "** DRY RUN **" echo echo "To create a release: ./releaser.sh v0.x.0" else export VERSION="$1" generate_changelog "${MERGED_PRS}" | gh release create "${VERSION}" -t "${VERSION}" -p -F - fi rm -f "${MERGED_PRS}" ================================================ FILE: scripts/update_route53_k8s_txt_owner.py ================================================ #!/usr/bin/env python # Copyright 2018 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. # This is a script that we wrote to try to help the migration over to using external-dns. # This script looks at kubernetes ingresses and services (which are the two things we have # external-dns looking at) and compares them to existing TXT and A records in route53 to # find out where there are gaps. It then assigns the heritage and owner TXT records where # needed so external-dns can take over managing those resources. You can modify the script # to only look at one or the other if needed. # # pip install kubernetes boto3 import boto3 from kubernetes import client, config # replace with your hosted zone id hosted_zone_id = '' # replace with your txt-owner-id you are using # inside of your external-dns controller txt_owner_id = '' # change to false if you have external-dns not looking at services external_dns_manages_services = True # change to false if you have external-dns not looking at ingresses external_dns_manages_ingresses = True config.load_kube_config() # grab all the domains that k8s thinks it is going to # manage (services with domainName specified and # ingress hosts) k8s_domains = [] if external_dns_manages_services: v1 = client.CoreV1Api() svcs = v1.list_service_for_all_namespaces() for i in svcs.items: annotations = i.metadata.annotations if annotations is not None and 'domainName' in annotations: k8s_domains.extend(annotations['domainName'].split(',')) if external_dns_manages_ingresses: ev1 = client.NetworkingV1Api() ings = ev1.list_ingress_for_all_namespaces() for i in ings.items: for r in i.spec.rules: if r.host not in k8s_domains: k8s_domains.append(r.host) r53client = boto3.client('route53') # grab the existing route53 domains and identify gaps where a domain may be # missing a txt record pair existing_r53_txt_domains=[] existing_r53_domains=[] has_next = True next_record_name, next_record_type='','' while has_next: if next_record_name is not '' and next_record_type is not '': resource_records = r53client.list_resource_record_sets(HostedZoneId=hosted_zone_id, StartRecordName=next_record_name, StartRecordType=next_record_type) else: resource_records = r53client.list_resource_record_sets(HostedZoneId=hosted_zone_id) for r in resource_records['ResourceRecordSets']: if r['Type'] == 'TXT': existing_r53_txt_domains.append(r['Name'][:-1]) elif r['Type'] == 'A': existing_r53_domains.append(r['Name'][:-1]) has_next = resource_records['IsTruncated'] if has_next: next_record_name, next_record_type = resource_records['NextRecordName'], resource_records['NextRecordType'] # grab only the domains in route53 that kubernetes is managing r53_k8s_domains = [r for r in k8s_domains if r in existing_r53_domains] # from those find the ones that do not have matching txt entries missing_k8s_txt = [r for r in r53_k8s_domains if r not in existing_r53_txt_domains] # make the change batch for the route53 call, modify this as needed change_batch=[] for r in missing_k8s_txt: change_batch.append( { 'Action': 'CREATE', 'ResourceRecordSet': { 'Name': r, 'Type': 'TXT', 'TTL': 300, 'ResourceRecords': [ { 'Value': '\heritage=external-dns,owner="' + txt_owner_id + '\"' }, ] } }) print('This will create the following resources') print(change_batch) response = input("Good to go? ") if response.lower() in ['y', 'yes', 'yup', 'ok', 'sure', 'why not', 'why not?']: print('Updating route53') change_response = r53client.change_resource_record_sets( HostedZoneId=hosted_zone_id, ChangeBatch={ 'Changes': change_batch }) print('Submitted change request to route53. Details below.') print(change_response) else: print('No changes were made') ================================================ FILE: scripts/version-updater.sh ================================================ #!/bin/bash set -e PREV_TAG=$1 NEW_TAG=$2 sed -i -e "s/newTag: .*/newTag: ${NEW_TAG}/g" kustomize/kustomization.yaml git add kustomize/kustomization.yaml sed -i -e "s/${PREV_TAG}/${NEW_TAG}/g" *.md docs/*.md docs/*/*.md git add *.md docs/*.md docs/*/*.md git commit -sm "chore(release): updates kustomize & docs with ${NEW_TAG}" ================================================ FILE: source/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - source ================================================ FILE: source/ambassador_host.go ================================================ /* Copyright 2020 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 source import ( "context" "errors" "fmt" "strings" ambassador "github.com/datawire/ambassador/pkg/api/getambassador.io/v2" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/informers" ) const ( // ambHostAnnotation is the annotation in the Host that maps to a Service ambHostAnnotation = "external-dns.ambassador-service" // groupName is the group name for the Ambassador API groupName = "getambassador.io" ) var ( schemeGroupVersion = schema.GroupVersion{Group: groupName, Version: "v2"} ambHostGVR = schemeGroupVersion.WithResource("hosts") ) // ambassadorHostSource is an implementation of Source for Ambassador Host objects. // The IngressRoute implementation uses the spec.virtualHost.fqdn value for the hostname. // Use annotations.TargetKey to explicitly set Endpoint. // // +externaldns:source:name=ambassador-host // +externaldns:source:category=Ingress Controllers // +externaldns:source:description=Creates DNS entries from Ambassador Host resources // +externaldns:source:resources=Host.getambassador.io // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=false // +externaldns:source:events=false // +externaldns:source:provider-specific=true type ambassadorHostSource struct { dynamicKubeClient dynamic.Interface kubeClient kubernetes.Interface namespace string annotationFilter string ambassadorHostInformer kubeinformers.GenericInformer unstructuredConverter *unstructuredConverter labelSelector labels.Selector } // NewAmbassadorHostSource creates a new ambassadorHostSource with the given config. func NewAmbassadorHostSource( ctx context.Context, dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, cfg *Config, ) (Source, error) { // Use shared informer to listen for add/update/delete of Host in the specified namespace. // Set resync period to 0, to prevent processing when nothing has changed. informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil) ambassadorHostInformer := informerFactory.ForResource(ambHostGVR) // Add default resource event handlers to properly initialize informer. _, _ = ambassadorHostInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil { return nil, err } uc, err := newUnstructuredConverter() if err != nil { return nil, fmt.Errorf("failed to setup Unstructured Converter: %w", err) } return &ambassadorHostSource{ dynamicKubeClient: dynamicKubeClient, kubeClient: kubeClient, namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, ambassadorHostInformer: ambassadorHostInformer, unstructuredConverter: uc, labelSelector: cfg.LabelFilter, }, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all Hosts in the source's namespace(s). func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { hosts, err := sc.ambassadorHostInformer.Lister().ByNamespace(sc.namespace).List(sc.labelSelector) if err != nil { return nil, err } // Get a list of Ambassador Host resources var ambassadorHosts []*ambassador.Host for _, hostObj := range hosts { unstructuredHost, ok := hostObj.(*unstructured.Unstructured) if !ok { return nil, errors.New("could not convert") } host := &ambassador.Host{} err := sc.unstructuredConverter.scheme.Convert(unstructuredHost, host, nil) if err != nil { return nil, err } ambassadorHosts = append(ambassadorHosts, host) } // Filter Ambassador Hosts ambassadorHosts, err = annotations.Filter(ambassadorHosts, sc.annotationFilter) if err != nil { return nil, fmt.Errorf("failed to filter Ambassador Hosts by annotation: %w", err) } var endpoints []*endpoint.Endpoint for _, host := range ambassadorHosts { fullname := fmt.Sprintf("%s/%s", host.Namespace, host.Name) // look for the "external-dns.ambassador-service" annotation. If it is not there then just ignore this `Host` service, found := host.Annotations[ambHostAnnotation] if !found { log.Debugf("Host %s ignored: no annotation %q found", fullname, ambHostAnnotation) continue } targets := annotations.TargetsFromTargetAnnotation(host.Annotations) if len(targets) == 0 { targets, err = sc.targetsFromAmbassadorLoadBalancer(ctx, service) if err != nil { log.Warningf("Could not find targets for service %s for Host %s: %v", service, fullname, err) continue } } hostEndpoints := sc.endpointsFromHost(host, targets) if endpoint.HasNoEmptyEndpoints(hostEndpoints, types.AmbassadorHost, host) { continue } log.Debugf("Endpoints generated from Host: %s: %v", fullname, hostEndpoints) endpoints = append(endpoints, hostEndpoints...) } return MergeEndpoints(endpoints), nil } // endpointsFromHost extracts the endpoints from a Host object func (sc *ambassadorHostSource) endpointsFromHost(host *ambassador.Host, targets endpoint.Targets) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint resource := fmt.Sprintf("host/%s/%s", host.Namespace, host.Name) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(host.Annotations) ttl := annotations.TTLFromAnnotations(host.Annotations, resource) if host.Spec != nil { hostname := host.Spec.Hostname if hostname != "" { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } return endpoints } func (sc *ambassadorHostSource) targetsFromAmbassadorLoadBalancer(ctx context.Context, service string) (endpoint.Targets, error) { lbNamespace, lbName, err := parseAmbLoadBalancerService(service) if err != nil { return nil, err } svc, err := sc.kubeClient.CoreV1().Services(lbNamespace).Get(ctx, lbName, metav1.GetOptions{}) if err != nil { return nil, err } targets := extractLoadBalancerTargets(svc, false) return targets, nil } // parseAmbLoadBalancerService returns a name/namespace tuple from the annotation in // an Ambassador Host CRD // // This is a thing because Ambassador has historically supported cross-namespace // references using a name.namespace syntax, but here we want to also support // namespace/name. // // Returns namespace, name, error. func parseAmbLoadBalancerService(service string) (string, string, error) { // Start by assuming that we have namespace/name. parts := strings.Split(service, "/") if len(parts) == 1 { // No "/" at all, so let's try for name.namespace. To be consistent with the // rest of Ambassador, use SplitN to limit this to one split, so that e.g. // svc.foo.bar uses service "svc" in namespace "foo.bar". parts = strings.SplitN(service, ".", 2) if len(parts) == 2 { // We got a namespace, great. name := parts[0] namespace := parts[1] return namespace, name, nil } // If here, we have no separator, so the whole string is the service, and // we can assume the default namespace. name := service namespace := "default" return namespace, name, nil } else if len(parts) == 2 { // This is "namespace/name". Note that the name could be qualified, // which is fine. namespace := parts[0] name := parts[1] return namespace, name, nil } // If we got here, this string is simply ill-formatted. Return an error. return "", "", fmt.Errorf("invalid external-dns service: %s", service) } func (sc *ambassadorHostSource) AddEventHandler(_ context.Context, _ func()) { } // unstructuredConverter handles conversions between unstructured.Unstructured and Ambassador types type unstructuredConverter struct { // scheme holds an initializer for converting Unstructured to a type scheme *runtime.Scheme } // newUnstructuredConverter returns a new unstructuredConverter initialized func newUnstructuredConverter() (*unstructuredConverter, error) { uc := &unstructuredConverter{ scheme: runtime.NewScheme(), } // Setup converter to understand custom CRD types ambassador.AddToScheme(uc.scheme) // Add the core types we need if err := scheme.AddToScheme(uc.scheme); err != nil { return nil, err } return uc, nil } ================================================ FILE: source/ambassador_host_test.go ================================================ /* Copyright 2019 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 source import ( "fmt" "testing" ambassador "github.com/datawire/ambassador/pkg/api/getambassador.io/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" fakeDynamic "k8s.io/client-go/dynamic/fake" fakeKube "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) const defaultAmbassadorNamespace = "ambassador" const defaultAmbassadorServiceName = "ambassador" // This is a compile-time validation that ambassadorHostSource is a Source. var _ Source = &ambassadorHostSource{} type AmbassadorSuite struct { suite.Suite } func TestAmbassadorSource(t *testing.T) { suite.Run(t, new(AmbassadorSuite)) t.Run("Interface", testAmbassadorSourceImplementsSource) } // testAmbassadorSourceImplementsSource tests that ambassadorHostSource is a valid Source. func testAmbassadorSourceImplementsSource(t *testing.T) { require.Implements(t, (*Source)(nil), new(ambassadorHostSource)) } func TestAmbassadorHostSource(t *testing.T) { t.Parallel() hostAnnotation := fmt.Sprintf("%s/%s", defaultAmbassadorNamespace, defaultAmbassadorServiceName) for _, ti := range []struct { title string annotationFilter string labelSelector labels.Selector host ambassador.Host service v1.Service expected []*endpoint.Endpoint }{ { title: "Simple host", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, }, }, }, { title: "Service with load balancer hostname", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ Hostname: "dns.google", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"dns.google"}, }, }, }, { title: "Service with external IP", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "service-external-ip", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Spec: v1.ServiceSpec{ ExternalIPs: []string{"2.2.2.2"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"2.2.2.2"}, }, }, }, { title: "Host with target annotation", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, annotations.TargetKey: "3.3.3.3", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"3.3.3.3"}, }, }, }, { title: "Host with TTL annotation", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, annotations.TtlKey: "180", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: 180, }, }, }, { title: "Host with provider specific annotation", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, annotations.CloudflareProxiedKey: "true", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{{ Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true", }}, }, }, }, { title: "Host with missing Ambassador annotation", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "valid matching annotation filter expression", annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, "kubernetes.io/ingress.class": "external-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, }, }, }, { title: "valid non-matching annotation filter expression", annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, "kubernetes.io/ingress.class": "internal-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "invalid annotation filter expression", annotationFilter: "kubernetes.io/ingress.class in (invalid-ingress)", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, "kubernetes.io/ingress.class": "external-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "valid non-matching annotation filter label", annotationFilter: "kubernetes.io/ingress.class=external-ingress", labelSelector: labels.Everything(), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, "kubernetes.io/ingress.class": "internal-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "valid non-matching label filter expression", labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, "kubernetes.io/ingress.class": "internal-ingress", }, Labels: map[string]string{ "kubernetes.io/ingress.class": "internal-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", }}, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "valid matching label filter expression for single host", labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, }, Labels: map[string]string{ "kubernetes.io/ingress.class": "external-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", Hostname: "dns.google", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, }, { DNSName: "www.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"dns.google"}, }, }, }, { title: "valid matching label filter expression and matching annotation filter", annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, "kubernetes.io/ingress.class": "external-ingress", }, Labels: map[string]string{ "kubernetes.io/ingress.class": "external-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", Hostname: "dns.google", }}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, }, { DNSName: "www.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"dns.google"}, }, }, }, { title: "valid non matching label filter expression and valid matching annotation filter", annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, "kubernetes.io/ingress.class": "external-ingress", }, Labels: map[string]string{ "kubernetes.io/ingress.class": "internal-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", Hostname: "dns.google", }}, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "valid matching label filter expression and non matching annotation filter", annotationFilter: "kubernetes.io/ingress.class in (external-ingress)", labelSelector: labels.SelectorFromSet(labels.Set{"kubernetes.io/ingress.class": "external-ingress"}), host: ambassador.Host{ ObjectMeta: metav1.ObjectMeta{ Name: "basic-host", Annotations: map[string]string{ ambHostAnnotation: hostAnnotation, "kubernetes.io/ingress.class": "internal-ingress", }, Labels: map[string]string{ "kubernetes.io/ingress.class": "external-ingress", }, }, Spec: &ambassador.HostSpec{ Hostname: "www.example.org", }, }, service: v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: defaultAmbassadorServiceName, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{ IP: "1.1.1.1", Hostname: "dns.google", }}, }, }, }, expected: []*endpoint.Endpoint{}, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() ambassadorScheme := runtime.NewScheme() ambassador.AddToScheme(ambassadorScheme) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(ambassadorScheme) namespace := "default" // Create Ambassador service _, err := fakeKubernetesClient.CoreV1().Services(defaultAmbassadorNamespace).Create(t.Context(), &ti.service, metav1.CreateOptions{}) assert.NoError(t, err) // Create host resource host, err := createAmbassadorHost(&ti.host) assert.NoError(t, err) _, err = fakeDynamicClient.Resource(ambHostGVR).Namespace(namespace).Create(t.Context(), host, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewAmbassadorHostSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: namespace, AnnotationFilter: ti.annotationFilter, LabelFilter: ti.labelSelector, }) assert.NoError(t, err) assert.NotNil(t, source) endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) // Validate returned endpoints against expected endpoints. validateEndpoints(t, endpoints, ti.expected) }) } } func createAmbassadorHost(host *ambassador.Host) (*unstructured.Unstructured, error) { obj := &unstructured.Unstructured{} uc, _ := newUnstructuredConverter() err := uc.scheme.Convert(host, obj, nil) return obj, err } // TestParseAmbLoadBalancerService tests our parsing of Ambassador service info. func TestParseAmbLoadBalancerService(t *testing.T) { vectors := []struct { input string ns string svc string errstr string }{ {"svc", "default", "svc", ""}, {"ns/svc", "ns", "svc", ""}, {"svc.ns", "ns", "svc", ""}, {"svc.ns.foo.bar", "ns.foo.bar", "svc", ""}, {"ns/svc/foo/bar", "", "", "invalid external-dns service: ns/svc/foo/bar"}, {"ns/svc/foo.bar", "", "", "invalid external-dns service: ns/svc/foo.bar"}, {"ns.foo/svc/bar", "", "", "invalid external-dns service: ns.foo/svc/bar"}, } for _, v := range vectors { ns, svc, err := parseAmbLoadBalancerService(v.input) errstr := "" if err != nil { errstr = err.Error() } if v.ns != ns { t.Errorf("%s: got ns \"%s\", wanted \"%s\"", v.input, ns, v.ns) } if v.svc != svc { t.Errorf("%s: got svc \"%s\", wanted \"%s\"", v.input, svc, v.svc) } if v.errstr != errstr { t.Errorf("%s: got err \"%s\", wanted \"%s\"", v.input, errstr, v.errstr) } } } ================================================ FILE: source/annotations/annotations.go ================================================ /* 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 annotations import ( "math" ) const ( // DefaultAnnotationPrefix is the default annotation prefix used by external-dns DefaultAnnotationPrefix = "external-dns.alpha.kubernetes.io/" ttlMinimum = 1 ttlMaximum = math.MaxInt32 ) var ( // AnnotationKeyPrefix is set on all annotations consumed by external-dns (outside of user templates) // to provide easy filtering. Can be customized via SetAnnotationPrefix. AnnotationKeyPrefix = DefaultAnnotationPrefix // CloudflareProxiedKey The annotation used for determining if traffic will go through Cloudflare CloudflareProxiedKey = AnnotationKeyPrefix + "cloudflare-proxied" CloudflareCustomHostnameKey = AnnotationKeyPrefix + "cloudflare-custom-hostname" CloudflareRegionKey = AnnotationKeyPrefix + "cloudflare-region-key" CloudflareRecordCommentKey = AnnotationKeyPrefix + "cloudflare-record-comment" CloudflareTagsKey = AnnotationKeyPrefix + "cloudflare-tags" AWSPrefix = AnnotationKeyPrefix + "aws-" CoreDNSPrefix = AnnotationKeyPrefix + "coredns-" SCWPrefix = AnnotationKeyPrefix + "scw-" WebhookPrefix = AnnotationKeyPrefix + "webhook-" CloudflarePrefix = AnnotationKeyPrefix + "cloudflare-" TtlKey = AnnotationKeyPrefix + "ttl" SetIdentifierKey = AnnotationKeyPrefix + "set-identifier" AliasKey = AnnotationKeyPrefix + "alias" RecordTypeKey = AnnotationKeyPrefix + "record-type" TargetKey = AnnotationKeyPrefix + "target" // ControllerKey The annotation used for figuring out which controller is responsible ControllerKey = AnnotationKeyPrefix + "controller" // HostnameKey The annotation used for defining the desired hostname HostnameKey = AnnotationKeyPrefix + "hostname" // AccessKey The annotation used for specifying whether the public or private interface address is used AccessKey = AnnotationKeyPrefix + "access" // EndpointsTypeKey The annotation used for specifying the type of endpoints to use for headless services EndpointsTypeKey = AnnotationKeyPrefix + "endpoints-type" // Ingress the annotation used to determine if the gateway is implemented by an Ingress object Ingress = AnnotationKeyPrefix + "ingress" // IngressHostnameSourceKey The annotation used to determine the source of hostnames for ingresses. This is an optional field - all // available hostname sources are used if not specified. IngressHostnameSourceKey = AnnotationKeyPrefix + "ingress-hostname-source" // ControllerValue The value of the controller annotation so that we feel responsible ControllerValue = "dns-controller" // InternalHostnameKey The annotation used for defining the desired hostname InternalHostnameKey = AnnotationKeyPrefix + "internal-hostname" // The annotation used for defining the desired hostname source for gateways GatewayHostnameSourceKey = AnnotationKeyPrefix + "gateway-hostname-source" ) // SetAnnotationPrefix sets a custom annotation prefix and rebuilds all annotation keys. // This must be called before any sources are initialized. // The prefix must end with '/'. func SetAnnotationPrefix(prefix string) { AnnotationKeyPrefix = prefix // Cloudflare annotations CloudflareProxiedKey = AnnotationKeyPrefix + "cloudflare-proxied" CloudflareCustomHostnameKey = AnnotationKeyPrefix + "cloudflare-custom-hostname" CloudflareRegionKey = AnnotationKeyPrefix + "cloudflare-region-key" CloudflareRecordCommentKey = AnnotationKeyPrefix + "cloudflare-record-comment" CloudflareTagsKey = AnnotationKeyPrefix + "cloudflare-tags" // Provider prefixes AWSPrefix = AnnotationKeyPrefix + "aws-" CoreDNSPrefix = AnnotationKeyPrefix + "coredns-" SCWPrefix = AnnotationKeyPrefix + "scw-" WebhookPrefix = AnnotationKeyPrefix + "webhook-" CloudflarePrefix = AnnotationKeyPrefix + "cloudflare-" // Core annotations TtlKey = AnnotationKeyPrefix + "ttl" SetIdentifierKey = AnnotationKeyPrefix + "set-identifier" AliasKey = AnnotationKeyPrefix + "alias" RecordTypeKey = AnnotationKeyPrefix + "record-type" TargetKey = AnnotationKeyPrefix + "target" ControllerKey = AnnotationKeyPrefix + "controller" HostnameKey = AnnotationKeyPrefix + "hostname" AccessKey = AnnotationKeyPrefix + "access" EndpointsTypeKey = AnnotationKeyPrefix + "endpoints-type" Ingress = AnnotationKeyPrefix + "ingress" IngressHostnameSourceKey = AnnotationKeyPrefix + "ingress-hostname-source" InternalHostnameKey = AnnotationKeyPrefix + "internal-hostname" GatewayHostnameSourceKey = AnnotationKeyPrefix + "gateway-hostname-source" } ================================================ FILE: source/annotations/annotations_test.go ================================================ /* 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 annotations import ( "testing" "github.com/stretchr/testify/assert" ) func TestSetAnnotationPrefix(t *testing.T) { t.Cleanup(func() { SetAnnotationPrefix(DefaultAnnotationPrefix) }) // Test custom prefix customPrefix := "custom.io/" SetAnnotationPrefix(customPrefix) assert.Equal(t, customPrefix, AnnotationKeyPrefix) assert.Equal(t, "custom.io/hostname", HostnameKey) assert.Equal(t, "custom.io/internal-hostname", InternalHostnameKey) assert.Equal(t, "custom.io/ttl", TtlKey) assert.Equal(t, "custom.io/target", TargetKey) assert.Equal(t, "custom.io/controller", ControllerKey) assert.Equal(t, "custom.io/cloudflare-proxied", CloudflareProxiedKey) assert.Equal(t, "custom.io/cloudflare-custom-hostname", CloudflareCustomHostnameKey) assert.Equal(t, "custom.io/cloudflare-region-key", CloudflareRegionKey) assert.Equal(t, "custom.io/cloudflare-record-comment", CloudflareRecordCommentKey) assert.Equal(t, "custom.io/cloudflare-tags", CloudflareTagsKey) assert.Equal(t, "custom.io/aws-", AWSPrefix) assert.Equal(t, "custom.io/coredns-", CoreDNSPrefix) assert.Equal(t, "custom.io/scw-", SCWPrefix) assert.Equal(t, "custom.io/webhook-", WebhookPrefix) assert.Equal(t, "custom.io/cloudflare-", CloudflarePrefix) assert.Equal(t, "custom.io/set-identifier", SetIdentifierKey) assert.Equal(t, "custom.io/alias", AliasKey) assert.Equal(t, "custom.io/access", AccessKey) assert.Equal(t, "custom.io/endpoints-type", EndpointsTypeKey) assert.Equal(t, "custom.io/ingress", Ingress) assert.Equal(t, "custom.io/ingress-hostname-source", IngressHostnameSourceKey) // ControllerValue should remain constant assert.Equal(t, "dns-controller", ControllerValue) } func TestDefaultAnnotationPrefix(t *testing.T) { t.Cleanup(func() { SetAnnotationPrefix(DefaultAnnotationPrefix) }) SetAnnotationPrefix(DefaultAnnotationPrefix) assert.Equal(t, DefaultAnnotationPrefix, AnnotationKeyPrefix) assert.Equal(t, DefaultAnnotationPrefix+"hostname", HostnameKey) assert.Equal(t, DefaultAnnotationPrefix+"internal-hostname", InternalHostnameKey) assert.Equal(t, DefaultAnnotationPrefix+"ttl", TtlKey) assert.Equal(t, DefaultAnnotationPrefix+"controller", ControllerKey) } func TestSetAnnotationPrefixMultipleTimes(t *testing.T) { t.Cleanup(func() { SetAnnotationPrefix(DefaultAnnotationPrefix) }) // Set first custom prefix SetAnnotationPrefix("first.io/") assert.Equal(t, "first.io/", AnnotationKeyPrefix) assert.Equal(t, "first.io/hostname", HostnameKey) // Set second custom prefix SetAnnotationPrefix("second.io/") assert.Equal(t, "second.io/", AnnotationKeyPrefix) assert.Equal(t, "second.io/hostname", HostnameKey) // Restore to default SetAnnotationPrefix(DefaultAnnotationPrefix) assert.Equal(t, DefaultAnnotationPrefix, AnnotationKeyPrefix) assert.Equal(t, DefaultAnnotationPrefix+"hostname", HostnameKey) } ================================================ FILE: source/annotations/filter.go ================================================ /* 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 annotations import ( "strings" log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/labels" ) // AnnotatedObject represents any Kubernetes object with annotations type AnnotatedObject interface { GetAnnotations() map[string]string } // Filter filters a slice of objects by annotation selector. // Returns all items if annotationFilter is empty. func Filter[T AnnotatedObject](items []T, filter string) ([]T, error) { if filter == "" || strings.TrimSpace(filter) == "" { return items, nil } selector, err := ParseFilter(filter) if err != nil { return nil, err } if selector.Empty() { return items, nil } filtered := make([]T, 0, len(items)) for _, item := range items { if selector.Matches(labels.Set(item.GetAnnotations())) { filtered = append(filtered, item) } } log.Debugf("filtered '%d' services out of '%d' with annotation filter '%s'", len(filtered), len(items), filter) return filtered, nil } ================================================ FILE: source/annotations/filter_test.go ================================================ /* 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 annotations import ( "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) // Mock object implementing AnnotatedObject type mockObj struct { annotations map[string]string } func (m mockObj) GetAnnotations() map[string]string { return m.annotations } func TestFilter(t *testing.T) { tests := []struct { name string items []mockObj filter string expected []mockObj expectError bool }{ { name: "Empty filter returns all", items: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"baz": "qux"}}, }, filter: "", expected: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"baz": "qux"}}, }, }, { name: "Matching items", items: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"foo": "baz"}}, }, filter: "foo=bar", expected: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, }, }, { name: "No matching items", items: []mockObj{ {annotations: map[string]string{"foo": "baz"}}, }, filter: "foo=bar", expected: []mockObj{}, }, { name: "Whitespace filter returns all", items: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"baz": "qux"}}, }, filter: " ", expected: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"baz": "qux"}}, }, }, { name: "empty filter returns all", items: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"baz": "qux"}}, }, filter: "", expected: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"baz": "qux"}}, }, }, { name: "invalid filter returns error", items: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"baz": "qux"}}, }, filter: "=invalid", expected: []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"baz": "qux"}}, }, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := Filter(tt.items, tt.filter) if tt.expectError { require.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tt.expected, result) } }) } } func TestFilter_LogOutput(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) items := []mockObj{ {annotations: map[string]string{"foo": "bar"}}, {annotations: map[string]string{"foo": "baz"}}, } filter := "foo=bar" _, _ = Filter(items, filter) logtest.TestHelperLogContains("filtered '1' services out of '2' with annotation filter 'foo=bar'", hook, t) } ================================================ FILE: source/annotations/processors.go ================================================ /* 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 annotations import ( "strconv" "strings" "time" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/external-dns/endpoint" ) const ( skipCtrlMsg = "Skipping '%s/%s/%s' because controller '%s' value does not match, found: '%s', required: '%s'" ) func hasAliasFromAnnotations(annotations map[string]string) bool { aliasAnnotation, ok := annotations[AliasKey] return ok && aliasAnnotation == "true" } // TTLFromAnnotations extracts the TTL from the annotations of the given resource. func TTLFromAnnotations(annotations map[string]string, resource string) endpoint.TTL { ttlNotConfigured := endpoint.TTL(0) ttlAnnotation, ok := annotations[TtlKey] if !ok { return ttlNotConfigured } ttlValue, err := parseTTL(ttlAnnotation) if err != nil { log.Warnf("%s: %q is not a valid TTL value: %v", resource, ttlAnnotation, err) return ttlNotConfigured } if ttlValue < ttlMinimum || ttlValue > ttlMaximum { log.Warnf("TTL value %q must be between [%d, %d]", ttlValue, ttlMinimum, ttlMaximum) return ttlNotConfigured } return endpoint.TTL(ttlValue) } // IsControllerMismatch returns true when the resource should be skipped because // the controller annotation is present and does not match the expected controller value. // It also logs the reason. func IsControllerMismatch( entity metav1.ObjectMetaAccessor, rType string, ) bool { value, ok := entity.GetObjectMeta().GetAnnotations()[ControllerKey] if ok && value != ControllerValue { log.Debugf(skipCtrlMsg, rType, entity.GetObjectMeta().GetNamespace(), entity.GetObjectMeta().GetName(), ControllerKey, value, ControllerValue) return true } return false } // parseTTL parses TTL from string, returning duration in seconds. // parseTTL supports both integers like "600" and durations based // on Go Duration like "10m", hence "600" and "10m" represent the same value. // // Note: for durations like "1.5s" the fraction is omitted (resulting in 1 second for the example). func parseTTL(s string) (int64, error) { ttlDuration, errDuration := time.ParseDuration(s) if errDuration != nil { ttlInt, err := strconv.ParseInt(s, 10, 64) if err != nil { return 0, errDuration } return ttlInt, nil } return int64(ttlDuration.Seconds()), nil } // ParseFilter parses an annotation filter string into a labels.Selector. // Returns nil if the annotation filter is invalid. func ParseFilter(annotationFilter string) (labels.Selector, error) { labelSelector, err := metav1.ParseToLabelSelector(annotationFilter) if err != nil { return nil, err } selector, err := metav1.LabelSelectorAsSelector(labelSelector) if err != nil { return nil, err } return selector, nil } // TargetsFromTargetAnnotation gets endpoints from optional "target" annotation. // Returns empty endpoints array if none are found. func TargetsFromTargetAnnotation(annotations map[string]string) endpoint.Targets { var targets endpoint.Targets // Get the desired hostname of the ingress from the annotation. targetAnnotation, ok := annotations[TargetKey] if ok && targetAnnotation != "" { // splits the hostname annotation and removes the trailing periods targetsList := SplitHostnameAnnotation(targetAnnotation) for _, targetHostname := range targetsList { targetHostname = strings.TrimSuffix(targetHostname, ".") targets = append(targets, targetHostname) } } return targets } // HostnamesFromAnnotations extracts the hostnames from the given annotations map. // It returns a slice of hostnames if the HostnameKey annotation is present, otherwise it returns nil. func HostnamesFromAnnotations(input map[string]string) []string { return extractHostnamesFromAnnotations(input, HostnameKey) } // InternalHostnamesFromAnnotations extracts the internal hostnames from the given annotations map. // It returns a slice of internal hostnames if the InternalHostnameKey annotation is present, otherwise it returns nil. func InternalHostnamesFromAnnotations(input map[string]string) []string { return extractHostnamesFromAnnotations(input, InternalHostnameKey) } // SplitHostnameAnnotation splits a comma-separated hostname annotation string into a slice of hostnames. // It trims any leading or trailing whitespace and removes any spaces within the anno func SplitHostnameAnnotation(input string) []string { return strings.Split(strings.TrimSpace(strings.ReplaceAll(input, " ", "")), ",") } func extractHostnamesFromAnnotations(input map[string]string, key string) []string { annotation, ok := input[key] if !ok { return nil } return SplitHostnameAnnotation(annotation) } ================================================ FILE: source/annotations/processors_test.go ================================================ /* 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 annotations import ( "fmt" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/external-dns/endpoint" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) // helper implementing metav1.ObjectMetaAccessor for tests type objectUnderTest struct { meta metav1.ObjectMeta } func (t *objectUnderTest) GetObjectMeta() metav1.Object { return &t.meta } func TestParseAnnotationFilter(t *testing.T) { tests := []struct { name string annotationFilter string expectedSelector labels.Selector expectError bool }{ { name: "valid annotation filter", annotationFilter: "key1=value1,key2=value2", expectedSelector: labels.Set{"key1": "value1", "key2": "value2"}.AsSelector(), expectError: false, }, { name: "invalid annotation filter", annotationFilter: "key1==value1", expectedSelector: labels.Set{"key1": "value1"}.AsSelector(), expectError: false, }, { name: "empty annotation filter", annotationFilter: "", expectedSelector: labels.Set{}.AsSelector(), expectError: false, }, { name: "wrong annotation filter", annotationFilter: "=test", expectedSelector: nil, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { selector, err := ParseFilter(tt.annotationFilter) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.expectedSelector, selector) } }) } } func TestTargetsFromTargetAnnotation(t *testing.T) { tests := []struct { name string annotations map[string]string expected endpoint.Targets }{ { name: "no target annotation", annotations: map[string]string{}, expected: endpoint.Targets(nil), }, { name: "single target annotation", annotations: map[string]string{ TargetKey: "example.com", }, expected: endpoint.Targets{"example.com"}, }, { name: "multiple target annotations", annotations: map[string]string{ TargetKey: "example.com,example.org", }, expected: endpoint.Targets{"example.com", "example.org"}, }, { name: "target annotation with trailing periods", annotations: map[string]string{ TargetKey: "example.com.,example.org.", }, expected: endpoint.Targets{"example.com", "example.org"}, }, { name: "target annotation with spaces", annotations: map[string]string{ TargetKey: " example.com , example.org ", }, expected: endpoint.Targets{"example.com", "example.org"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := TargetsFromTargetAnnotation(tt.annotations) assert.Equal(t, tt.expected, result) }) } } func TestTTLFromAnnotations(t *testing.T) { tests := []struct { name string annotations map[string]string resource string expectedTTL endpoint.TTL }{ { name: "no TTL annotation", annotations: map[string]string{}, resource: "test-resource", expectedTTL: endpoint.TTL(0), }, { name: "valid TTL annotation", annotations: map[string]string{ TtlKey: "600", }, resource: "test-resource", expectedTTL: endpoint.TTL(600), }, { name: "invalid TTL annotation", annotations: map[string]string{ TtlKey: "invalid", }, resource: "test-resource", expectedTTL: endpoint.TTL(0), }, { name: "TTL annotation out of range", annotations: map[string]string{ TtlKey: "999999", }, resource: "test-resource", expectedTTL: endpoint.TTL(999999), }, { name: "TTL annotation not present", annotations: map[string]string{"foo": "bar"}, expectedTTL: endpoint.TTL(0), }, { name: "TTL annotation value is not a number", annotations: map[string]string{TtlKey: "foo"}, expectedTTL: endpoint.TTL(0), }, { name: "TTL annotation value is empty", annotations: map[string]string{TtlKey: ""}, expectedTTL: endpoint.TTL(0), }, { name: "TTL annotation value is negative number", annotations: map[string]string{TtlKey: "-1"}, expectedTTL: endpoint.TTL(0), }, { name: "TTL annotation value is too high", annotations: map[string]string{TtlKey: fmt.Sprintf("%d", 1<<32)}, expectedTTL: endpoint.TTL(0), }, { name: "TTL annotation value is set correctly using integer", annotations: map[string]string{TtlKey: "60"}, expectedTTL: endpoint.TTL(60), }, { name: "TTL annotation value is set correctly using duration (whole)", annotations: map[string]string{TtlKey: "10m"}, expectedTTL: endpoint.TTL(600), }, { name: "TTL annotation value is set correctly using duration (fractional)", annotations: map[string]string{TtlKey: "20.5s"}, expectedTTL: endpoint.TTL(20), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ttl := TTLFromAnnotations(tt.annotations, tt.resource) assert.Equal(t, tt.expectedTTL, ttl) }) } } func TestGetAliasFromAnnotations(t *testing.T) { tests := []struct { name string annotations map[string]string expected bool }{ { name: "alias annotation exists and is true", annotations: map[string]string{AliasKey: "true"}, expected: true, }, { name: "alias annotation exists and is false", annotations: map[string]string{AliasKey: "false"}, expected: false, }, { name: "alias annotation does not exist", annotations: map[string]string{}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := hasAliasFromAnnotations(tt.annotations) assert.Equal(t, tt.expected, result) }) } } func TestHostnamesFromAnnotations(t *testing.T) { tests := []struct { name string annotations map[string]string expected []string }{ { name: "no hostname annotation", annotations: map[string]string{}, expected: nil, }, { name: "single hostname annotation", annotations: map[string]string{ HostnameKey: "example.com", }, expected: []string{"example.com"}, }, { name: "multiple hostname annotations", annotations: map[string]string{ HostnameKey: "example.com,example.org", }, expected: []string{"example.com", "example.org"}, }, { name: "hostname annotation with spaces", annotations: map[string]string{ HostnameKey: " example.com , example.org ", }, expected: []string{"example.com", "example.org"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := HostnamesFromAnnotations(tt.annotations) assert.Equal(t, tt.expected, result) }) } } func TestSplitHostnameAnnotation(t *testing.T) { tests := []struct { name string annotation string expected []string }{ { name: "empty annotation", annotation: "", expected: []string{""}, }, { name: "single hostname", annotation: "example.com", expected: []string{"example.com"}, }, { name: "multiple hostnames", annotation: "example.com,example.org", expected: []string{"example.com", "example.org"}, }, { name: "hostnames with spaces", annotation: " example.com , example.org ", expected: []string{"example.com", "example.org"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := SplitHostnameAnnotation(tt.annotation) assert.Equal(t, tt.expected, result) }) } } func TestInternalHostnamesFromAnnotations(t *testing.T) { tests := []struct { name string annotations map[string]string expected []string }{ { name: "no internal hostname annotation", annotations: map[string]string{}, expected: nil, }, { name: "single internal hostname annotation", annotations: map[string]string{ InternalHostnameKey: "internal.example.com", }, expected: []string{"internal.example.com"}, }, { name: "multiple internal hostname annotations", annotations: map[string]string{ InternalHostnameKey: "internal.example.com,internal.example.org", }, expected: []string{"internal.example.com", "internal.example.org"}, }, { name: "internal hostname annotation with spaces", annotations: map[string]string{ InternalHostnameKey: " internal.example.com , internal.example.org ", }, expected: []string{"internal.example.com", "internal.example.org"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := InternalHostnamesFromAnnotations(tt.annotations) assert.Equal(t, tt.expected, result) }) } } func TestIsControllerMismatch(t *testing.T) { tests := []struct { name string annotations map[string]string entity objectUnderTest resourceType string debugMsg string expected bool }{ { name: "no controller annotation", entity: objectUnderTest{ meta: metav1.ObjectMeta{ Name: "my-service", Namespace: "default", Annotations: map[string]string{}, }, }, resourceType: "service", expected: false, }, { name: "non-matching controller annotation", entity: objectUnderTest{ meta: metav1.ObjectMeta{ Name: "my-service", Namespace: "default", Annotations: map[string]string{ ControllerKey: "other-controller", }, }, }, debugMsg: fmt.Sprintf("Skipping 'service/default/my-service' because controller '%s' value does not match, found: 'other-controller', required: '%s'", ControllerKey, ControllerValue), resourceType: "service", expected: true, }, { name: "empty controller value with annotation", entity: objectUnderTest{ meta: metav1.ObjectMeta{ Name: "test-ingress", Namespace: "kube-system", Annotations: map[string]string{ ControllerKey: "", }, }, }, debugMsg: fmt.Sprintf("Skipping 'ingress/kube-system/test-ingress' because controller '%s' value does not match, found: '', required: '%s'", ControllerKey, ControllerValue), resourceType: "ingress", expected: true, }, { name: "nil annotations", entity: objectUnderTest{ meta: metav1.ObjectMeta{ Name: "service", Namespace: "default", Annotations: nil, }, }, resourceType: "service", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) result := IsControllerMismatch(&tt.entity, tt.resourceType) assert.Equal(t, tt.expected, result) if tt.debugMsg != "" { logtest.TestHelperLogContains(tt.debugMsg, hook, t) } else { logtest.TestHelperLogNotContains("Skipping", hook, t) } }) } } ================================================ FILE: source/annotations/provider_specific.go ================================================ /* 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 annotations import ( "fmt" "strings" "sigs.k8s.io/external-dns/endpoint" ) func ProviderSpecificAnnotations(annotations map[string]string) (endpoint.ProviderSpecific, string) { providerSpecificAnnotations := endpoint.ProviderSpecific{} if hasAliasFromAnnotations(annotations) { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: "alias", Value: "true", }) } if v, ok := annotations[RecordTypeKey]; ok { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: endpoint.ProviderSpecificRecordType, Value: v, }) } setIdentifier := "" for k, v := range annotations { if k == SetIdentifierKey { setIdentifier = v } else if attr, ok := strings.CutPrefix(k, AWSPrefix); ok { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: fmt.Sprintf("aws/%s", attr), Value: v, }) } else if attr, ok := strings.CutPrefix(k, SCWPrefix); ok { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: fmt.Sprintf("scw/%s", attr), Value: v, }) } else if attr, ok := strings.CutPrefix(k, WebhookPrefix); ok { // Support for wildcard annotations for webhook providers providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: fmt.Sprintf("webhook/%s", attr), Value: v, }) } else if attr, ok := strings.CutPrefix(k, CoreDNSPrefix); ok { providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: fmt.Sprintf("coredns/%s", attr), Value: v, }) } else if strings.HasPrefix(k, CloudflarePrefix) { // TODO: unlike other providers which normalise to "provider/attr", // Cloudflare retains the full annotation key as the property name // (e.g. "external-dns.alpha.kubernetes.io/cloudflare-proxied"). // This is why RetainProviderProperties has a special case for cloudflare. // Should be aligned with the standard convention in a future change. switch { case strings.Contains(k, CloudflareCustomHostnameKey): providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: CloudflareCustomHostnameKey, Value: v, }) case strings.Contains(k, CloudflareProxiedKey): providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: CloudflareProxiedKey, Value: v, }) case strings.Contains(k, CloudflareRegionKey): providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: CloudflareRegionKey, Value: v, }) case strings.Contains(k, CloudflareRecordCommentKey): providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: CloudflareRecordCommentKey, Value: v, }) case strings.Contains(k, CloudflareTagsKey): providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ Name: CloudflareTagsKey, Value: v, }) } } } return providerSpecificAnnotations, setIdentifier } ================================================ FILE: source/annotations/provider_specific_test.go ================================================ /* 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 annotations import ( "strconv" "strings" "testing" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" ) func TestProviderSpecificAnnotations(t *testing.T) { tests := []struct { name string annotations map[string]string expected endpoint.ProviderSpecific setIdentifier string }{ { name: "no annotations", annotations: map[string]string{}, expected: endpoint.ProviderSpecific{}, setIdentifier: "", }, { name: "Cloudflare proxied annotation", annotations: map[string]string{ CloudflareProxiedKey: "true", }, expected: endpoint.ProviderSpecific{ {Name: CloudflareProxiedKey, Value: "true"}, }, setIdentifier: "", }, { name: "Cloudflare custom hostname annotation", annotations: map[string]string{ CloudflareCustomHostnameKey: "custom.example.com", }, expected: endpoint.ProviderSpecific{ {Name: CloudflareCustomHostnameKey, Value: "custom.example.com"}, }, setIdentifier: "", }, { name: "AWS annotation", annotations: map[string]string{ "external-dns.alpha.kubernetes.io/aws-weight": "100", }, expected: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "100"}, }, setIdentifier: "", }, { name: "CoreDNS annotation", annotations: map[string]string{ "external-dns.alpha.kubernetes.io/coredns-group": "g1", }, expected: endpoint.ProviderSpecific{ {Name: "coredns/group", Value: "g1"}, }, setIdentifier: "", }, { name: "Set identifier annotation", annotations: map[string]string{ SetIdentifierKey: "identifier", }, expected: endpoint.ProviderSpecific{}, setIdentifier: "identifier", }, { name: "Record type annotation", annotations: map[string]string{ RecordTypeKey: "ptr", }, expected: endpoint.ProviderSpecific{ {Name: endpoint.ProviderSpecificRecordType, Value: "ptr"}, }, setIdentifier: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, setIdentifier := ProviderSpecificAnnotations(tt.annotations) assert.Equal(t, tt.expected, result) assert.Equal(t, tt.setIdentifier, setIdentifier) for _, prop := range result { slashIdx := strings.Index(prop.Name, "/") if slashIdx == -1 || strings.HasPrefix(prop.Name, CloudflarePrefix) { continue } assert.NotContains(t, prop.Name[:slashIdx], ".", "property %q uses a full annotation name; only cloudflare is allowed to — use the short \"provider/attr\" form instead", prop.Name) } }) } } func TestGetProviderSpecificCloudflareAnnotations(t *testing.T) { for _, tc := range []struct { title string annotations map[string]string expectedKey string expectedValue string }{ { title: "Cloudflare tags annotation is set correctly", annotations: map[string]string{CloudflareTagsKey: "env:test,owner:team-a"}, expectedKey: CloudflareTagsKey, expectedValue: "env:test,owner:team-a", }, { title: "Cloudflare tags annotation among another annotations is set correctly", annotations: map[string]string{ "random annotation 1": "random value 1", CloudflareTagsKey: "env:test,owner:team-b", "random annotation 2": "random value 2"}, expectedKey: CloudflareTagsKey, expectedValue: "env:test,owner:team-b", }, } { t.Run(tc.title, func(t *testing.T) { providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) for _, providerSpecificAnnotation := range providerSpecificAnnotations { if providerSpecificAnnotation.Name == tc.expectedKey { assert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value) return } } t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %s", tc.expectedKey, tc.expectedValue) }) } for _, tc := range []struct { title string annotations map[string]string expectedKey string expectedValue bool }{ { title: "Cloudflare proxied annotation is set correctly to true", annotations: map[string]string{CloudflareProxiedKey: "true"}, expectedKey: CloudflareProxiedKey, expectedValue: true, }, { title: "Cloudflare proxied annotation is set correctly to false", annotations: map[string]string{CloudflareProxiedKey: "false"}, expectedKey: CloudflareProxiedKey, expectedValue: false, }, { title: "Cloudflare proxied annotation among another annotations is set correctly to true", annotations: map[string]string{ "random annotation 1": "random value 1", CloudflareProxiedKey: "false", "random annotation 2": "random value 2", }, expectedKey: CloudflareProxiedKey, expectedValue: false, }, } { t.Run(tc.title, func(t *testing.T) { providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) for _, providerSpecificAnnotation := range providerSpecificAnnotations { if providerSpecificAnnotation.Name == tc.expectedKey { assert.Equal(t, strconv.FormatBool(tc.expectedValue), providerSpecificAnnotation.Value) return } } t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %v", tc.expectedKey, tc.expectedValue) }) } for _, tc := range []struct { title string annotations map[string]string expectedKey string expectedValue string }{ { title: "Cloudflare region key annotation is set correctly", annotations: map[string]string{CloudflareRegionKey: "us"}, expectedKey: CloudflareRegionKey, expectedValue: "us", }, { title: "Cloudflare region key annotation among another annotations is set correctly", annotations: map[string]string{ "random annotation 1": "random value 1", CloudflareRegionKey: "us", "random annotation 2": "random value 2", }, expectedKey: CloudflareRegionKey, expectedValue: "us", }, { title: "Cloudflare DNS record comment annotation is set correctly", annotations: map[string]string{ CloudflareRecordCommentKey: "comment", }, expectedKey: CloudflareRecordCommentKey, expectedValue: "comment", }, } { t.Run(tc.title, func(t *testing.T) { providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) for _, providerSpecificAnnotation := range providerSpecificAnnotations { if providerSpecificAnnotation.Name == tc.expectedKey { assert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value) return } } t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %v", tc.expectedKey, tc.expectedValue) }) } for _, tc := range []struct { title string annotations map[string]string expectedKey string expectedValue string }{ { title: "Cloudflare custom hostname annotation is set correctly", annotations: map[string]string{CloudflareCustomHostnameKey: "a.foo.fancybar.com"}, expectedKey: CloudflareCustomHostnameKey, expectedValue: "a.foo.fancybar.com", }, { title: "Cloudflare custom hostname annotation among another annotations is set correctly", annotations: map[string]string{ "random annotation 1": "random value 1", CloudflareCustomHostnameKey: "a.foo.fancybar.com", "random annotation 2": "random value 2"}, expectedKey: CloudflareCustomHostnameKey, expectedValue: "a.foo.fancybar.com", }, } { t.Run(tc.title, func(t *testing.T) { providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) for _, providerSpecificAnnotation := range providerSpecificAnnotations { if providerSpecificAnnotation.Name == tc.expectedKey { assert.Equal(t, tc.expectedValue, providerSpecificAnnotation.Value) return } } t.Errorf("Cloudflare provider specific annotation %s is not set correctly to %s", tc.expectedKey, tc.expectedValue) }) } } func TestGetProviderSpecificAliasAnnotations(t *testing.T) { for _, tc := range []struct { title string annotations map[string]string expectedKey string expectedValue bool }{ { title: "alias annotation is set correctly to true", annotations: map[string]string{AliasKey: "true"}, expectedKey: AliasKey, expectedValue: true, }, { title: "alias annotation among another annotations is set correctly to true", annotations: map[string]string{ "random annotation 1": "random value 1", AliasKey: "true", "random annotation 2": "random value 2", }, expectedKey: AliasKey, expectedValue: true, }, } { t.Run(tc.title, func(t *testing.T) { providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) for _, providerSpecificAnnotation := range providerSpecificAnnotations { if providerSpecificAnnotation.Name == "alias" { assert.Equal(t, strconv.FormatBool(tc.expectedValue), providerSpecificAnnotation.Value) return } } t.Errorf("provider specific annotation alias is not set correctly to %v", tc.expectedValue) }) } for _, tc := range []struct { title string annotations map[string]string }{ { title: "alias annotation is set to false", annotations: map[string]string{AliasKey: "false"}, }, { title: "alias annotation is not set", annotations: map[string]string{ "random annotation 1": "random value 1", "random annotation 2": "random value 2", }, }, } { t.Run(tc.title, func(t *testing.T) { providerSpecificAnnotations, _ := ProviderSpecificAnnotations(tc.annotations) for _, providerSpecificAnnotation := range providerSpecificAnnotations { if providerSpecificAnnotation.Name == "alias" { t.Error("provider specific annotation alias is not expected to be set") } } }) } } // TestProviderSpecificPropertyNameConvention enforces that only Cloudflare may // emit the full annotation name (e.g. "external-dns.alpha.kubernetes.io/cloudflare-proxied") // as a property name. All other providers must normalise to the short "provider/attr" form // (e.g. "aws/weight"). If a new provider (e.g. azure-, ovh-) is added but accidentally // outputs the full annotation name, this test will catch it. func TestProviderSpecificPropertyNameConvention(t *testing.T) { annotations := map[string]string{ AnnotationKeyPrefix + "aws-weight": "10", AnnotationKeyPrefix + "scw-something": "val", AnnotationKeyPrefix + "webhook-something": "val", AnnotationKeyPrefix + "coredns-group": "g1", CloudflareProxiedKey: "true", CloudflareTagsKey: "tag1", CloudflareRegionKey: "us", CloudflareRecordCommentKey: "comment", CloudflareCustomHostnameKey: "host.example.com", AliasKey: "true", } props, _ := ProviderSpecificAnnotations(annotations) for _, prop := range props { name := prop.Name slashIdx := strings.Index(name, "/") if slashIdx == -1 { // No slash: provider-agnostic property (e.g. "alias") — always OK. continue } // Cloudflare exception: retains the full annotation name. if strings.HasPrefix(name, CloudflarePrefix) { continue } // All other providers must use the short "provider/attr" form. // The segment before "/" must be a plain word with no dots. providerSegment := name[:slashIdx] assert.NotContains(t, providerSegment, ".", "property %q uses a full annotation name; only cloudflare is allowed to — use the short \"provider/attr\" form instead", name) } } func TestGetProviderSpecificIdentifierAnnotations(t *testing.T) { for _, tc := range []struct { title string annotations map[string]string expectedResult map[string]string expectedIdentifier string }{ { title: "aws- provider specific annotations are set correctly", annotations: map[string]string{ "external-dns.alpha.kubernetes.io/aws-annotation-1": "value 1", SetIdentifierKey: "id1", "external-dns.alpha.kubernetes.io/aws-annotation-2": "value 2", }, expectedResult: map[string]string{ "aws/annotation-1": "value 1", "aws/annotation-2": "value 2", }, expectedIdentifier: "id1", }, { title: "scw- provider specific annotations are set correctly", annotations: map[string]string{ "external-dns.alpha.kubernetes.io/scw-annotation-1": "value 1", SetIdentifierKey: "id1", "external-dns.alpha.kubernetes.io/scw-annotation-2": "value 2", }, expectedResult: map[string]string{ "scw/annotation-1": "value 1", "scw/annotation-2": "value 2", }, expectedIdentifier: "id1", }, { title: "webhook- provider specific annotations are set correctly", annotations: map[string]string{ "external-dns.alpha.kubernetes.io/webhook-annotation-1": "value 1", SetIdentifierKey: "id1", "external-dns.alpha.kubernetes.io/webhook-annotation-2": "value 2", }, expectedResult: map[string]string{ "webhook/annotation-1": "value 1", "webhook/annotation-2": "value 2", }, expectedIdentifier: "id1", }, } { t.Run(tc.title, func(t *testing.T) { providerSpecificAnnotations, identifier := ProviderSpecificAnnotations(tc.annotations) assert.Equal(t, tc.expectedIdentifier, identifier) for expectedAnnotationKey, expectedAnnotationValue := range tc.expectedResult { expectedResultFound := false for _, providerSpecificAnnotation := range providerSpecificAnnotations { if providerSpecificAnnotation.Name == expectedAnnotationKey { assert.Equal(t, expectedAnnotationValue, providerSpecificAnnotation.Value) expectedResultFound = true break } } if !expectedResultFound { t.Errorf("provider specific annotation %s has not been set", expectedAnnotationKey) } } }) } } ================================================ FILE: source/compatibility.go ================================================ /* Copyright 2017 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 source import ( "strings" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/external-dns/endpoint" ) const ( mateAnnotationKey = "zalando.org/dnsname" moleculeAnnotationKey = "domainName" // kopsDNSControllerHostnameAnnotationKey is the annotation used for defining the desired hostname when kOps DNS controller compatibility mode kopsDNSControllerHostnameAnnotationKey = "dns.alpha.kubernetes.io/external" // kopsDNSControllerInternalHostnameAnnotationKey is the annotation used for defining the desired hostname when kOps DNS controller compatibility mode kopsDNSControllerInternalHostnameAnnotationKey = "dns.alpha.kubernetes.io/internal" ) // legacyEndpointsFromService tries to retrieve Endpoints from Services // annotated with legacy annotations. func legacyEndpointsFromService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) { switch sc.compatibility { case "mate": return legacyEndpointsFromMateService(svc), nil case "molecule": return legacyEndpointsFromMoleculeService(svc), nil case "kops-dns-controller": return legacyEndpointsFromDNSControllerService(svc, sc) } return []*endpoint.Endpoint{}, nil } // legacyEndpointsFromMateService tries to retrieve Endpoints from Services // annotated with Mate's annotation semantics. func legacyEndpointsFromMateService(svc *v1.Service) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // Get the desired hostname of the service from the annotation. hostname, ok := svc.Annotations[mateAnnotationKey] if !ok { return nil } // Create a corresponding endpoint for each configured external entrypoint. for _, lb := range svc.Status.LoadBalancer.Ingress { if lb.IP != "" { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, lb.IP)) } if lb.Hostname != "" { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeCNAME, lb.Hostname)) } } return endpoints } // legacyEndpointsFromMoleculeService tries to retrieve Endpoints from Services // annotated with Molecule Software's annotation semantics. func legacyEndpointsFromMoleculeService(svc *v1.Service) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // Check that the Service opted-in to being processed. if svc.Labels["dns"] != "route53" { return nil } // Get the desired hostname of the service from the annotation. hostnameAnnotation, ok := svc.Annotations[moleculeAnnotationKey] if !ok { return nil } hostnameList := strings.SplitSeq(strings.ReplaceAll(hostnameAnnotation, " ", ""), ",") for hostname := range hostnameList { // Create a corresponding endpoint for each configured external entrypoint. for _, lb := range svc.Status.LoadBalancer.Ingress { if lb.IP != "" { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, lb.IP)) } if lb.Hostname != "" { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeCNAME, lb.Hostname)) } } } return endpoints } // legacyEndpointsFromDNSControllerService tries to retrieve Endpoints from Services // annotated with DNS Controller's annotation semantics*. func legacyEndpointsFromDNSControllerService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) { switch svc.Spec.Type { case v1.ServiceTypeNodePort: return legacyEndpointsFromDNSControllerNodePortService(svc, sc) case v1.ServiceTypeLoadBalancer: return legacyEndpointsFromDNSControllerLoadBalancerService(svc), nil } return []*endpoint.Endpoint{}, nil } // legacyEndpointsFromDNSControllerNodePortService implements DNS controller's semantics for NodePort services. // It will use node role label to check if the node has the "node" role. This means control plane nodes and other // roles will not be used as targets. func legacyEndpointsFromDNSControllerNodePortService(svc *v1.Service, sc *serviceSource) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint // Get the desired hostname of the service from the annotations. hostnameAnnotation, isExternal := svc.Annotations[kopsDNSControllerHostnameAnnotationKey] internalHostnameAnnotation, isInternal := svc.Annotations[kopsDNSControllerInternalHostnameAnnotationKey] if !isExternal && !isInternal { return nil, nil } // if both annotations are set, we just return empty, mimicking what dns-controller does if isInternal && isExternal { return nil, nil } nodes, err := sc.nodeInformer.Lister().List(labels.Everything()) if err != nil { return nil, err } var hostnameList []string if isExternal { hostnameList = strings.Split(strings.ReplaceAll(hostnameAnnotation, " ", ""), ",") } else { hostnameList = strings.Split(strings.ReplaceAll(internalHostnameAnnotation, " ", ""), ",") } for _, hostname := range hostnameList { for _, node := range nodes { _, isNode := node.Labels["node-role.kubernetes.io/node"] if !isNode { continue } for _, address := range node.Status.Addresses { recordType := endpoint.SuitableType(address.Address) // IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well. if isExternal && (address.Type == v1.NodeExternalIP || (sc.exposeInternalIPv6 && address.Type == v1.NodeInternalIP && recordType == endpoint.RecordTypeAAAA)) { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, recordType, address.Address)) } if isInternal && address.Type == v1.NodeInternalIP { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, recordType, address.Address)) } } } } return endpoints, nil } // legacyEndpointsFromDNSControllerLoadBalancerService will respect both annotations, but // will not care if the load balancer actually is internal or not. func legacyEndpointsFromDNSControllerLoadBalancerService(svc *v1.Service) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // Get the desired hostname of the service from the annotations. hostnameAnnotation, hasExternal := svc.Annotations[kopsDNSControllerHostnameAnnotationKey] internalHostnameAnnotation, hasInternal := svc.Annotations[kopsDNSControllerInternalHostnameAnnotationKey] if !hasExternal && !hasInternal { return nil } var hostnameList []string if hasExternal { hostnameList = append(hostnameList, strings.Split(strings.ReplaceAll(hostnameAnnotation, " ", ""), ",")...) } if hasInternal { hostnameList = append(hostnameList, strings.Split(strings.ReplaceAll(internalHostnameAnnotation, " ", ""), ",")...) } for _, hostname := range hostnameList { // Create a corresponding endpoint for each configured external entrypoint. for _, lb := range svc.Status.LoadBalancer.Ingress { if lb.IP != "" { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeA, lb.IP)) } if lb.Hostname != "" { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, endpoint.RecordTypeCNAME, lb.Hostname)) } } } return endpoints } ================================================ FILE: source/connector.go ================================================ /* Copyright 2018 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 source import ( "context" "encoding/gob" "net" "time" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" ) const ( dialTimeout = 30 * time.Second ) // connectorSource is an implementation of Source that provides endpoints by connecting // to a remote tcp server. The encoding/decoding is done using encoder/gob package. // // +externaldns:source:name=connector // +externaldns:source:category=Special // +externaldns:source:description=Connects to a remote TCP server to receive DNS endpoints // +externaldns:source:resources=Remote TCP Server // +externaldns:source:filters= // +externaldns:source:namespace= // +externaldns:source:fqdn-template=false // +externaldns:source:provider-specific=false type connectorSource struct { remoteServer string } // NewConnectorSource creates a new connectorSource with the given config. func NewConnectorSource(remoteServer string) (Source, error) { return &connectorSource{ remoteServer: remoteServer, }, nil } // Endpoints returns endpoint objects. func (cs *connectorSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { endpoints := []*endpoint.Endpoint{} conn, err := net.DialTimeout("tcp", cs.remoteServer, dialTimeout) if err != nil { log.Errorf("Connection error: %v", err) return nil, err } defer conn.Close() decoder := gob.NewDecoder(conn) if err := decoder.Decode(&endpoints); err != nil { log.Errorf("Decode error: %v", err) return nil, err } log.Debugf("Received endpoints: %#v", endpoints) return MergeEndpoints(endpoints), nil } func (cs *connectorSource) AddEventHandler(_ context.Context, _ func()) {} ================================================ FILE: source/connector_test.go ================================================ /* Copyright 2018 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 source import ( "encoding/gob" "net" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "sigs.k8s.io/external-dns/endpoint" ) type ConnectorSuite struct { suite.Suite } func (suite *ConnectorSuite) SetupTest() { } func startServerToServeTargets(t *testing.T, endpoints []*endpoint.Endpoint) net.Listener { ln, err := net.Listen("tcp", "localhost:0") if err != nil { t.Fatal(err) } go func() { conn, err := ln.Accept() if err != nil { ln.Close() return } enc := gob.NewEncoder(conn) enc.Encode(endpoints) ln.Close() }() t.Logf("Server listening on %s", ln.Addr().String()) return ln } func TestConnectorSource(t *testing.T) { t.Parallel() suite.Run(t, new(ConnectorSuite)) t.Run("Interface", testConnectorSourceImplementsSource) t.Run("Endpoints", testConnectorSourceEndpoints) } // testConnectorSourceImplementsSource tests that connectorSource is a valid Source. func testConnectorSourceImplementsSource(t *testing.T) { assert.Implements(t, (*Source)(nil), new(connectorSource)) } // testConnectorSourceEndpoints tests that NewConnectorSource doesn't return an error. func testConnectorSourceEndpoints(t *testing.T) { for _, ti := range []struct { title string server bool expected []*endpoint.Endpoint expectError bool }{ { title: "invalid remote server", server: false, expectError: true, }, { title: "valid remote server with no endpoints", server: true, expectError: false, }, { title: "valid remote server", server: true, expected: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectError: false, }, { title: "valid remote server with multiple endpoints", server: true, expected: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, { DNSName: "xyz.example.org", Targets: endpoint.Targets{"abc.example.org"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 180, }, }, expectError: false, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() addr := "localhost:9999" if ti.server { ln := startServerToServeTargets(t, ti.expected) defer ln.Close() addr = ln.Addr().String() } cs, _ := NewConnectorSource(addr) endpoints, err := cs.Endpoints(t.Context()) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } // Validate returned endpoints against expected endpoints. validateEndpoints(t, endpoints, ti.expected) }) } } ================================================ FILE: source/contour_httpproxy.go ================================================ /* Copyright 2020 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 source import ( "context" "errors" "fmt" "text/template" projectcontour "github.com/projectcontour/contour/apis/projectcontour/v1" log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" kubeinformers "k8s.io/client-go/informers" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" ) // HTTPProxySource is an implementation of Source for ProjectContour HTTPProxy objects. // The HTTPProxy implementation uses the spec.virtualHost.fqdn value for the hostname. // Use annotations.TargetKey to explicitly set Endpoint. // // +externaldns:source:name=contour-httpproxy // +externaldns:source:category=Ingress Controllers // +externaldns:source:description=Creates DNS entries from Contour HTTPProxy resources // +externaldns:source:resources=HTTPProxy.projectcontour.io // +externaldns:source:filters=annotation // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true type httpProxySource struct { dynamicKubeClient dynamic.Interface namespace string annotationFilter string fqdnTemplate *template.Template combineFQDNAnnotation bool ignoreHostnameAnnotation bool httpProxyInformer kubeinformers.GenericInformer unstructuredConverter *UnstructuredConverter } // NewContourHTTPProxySource creates a new contourHTTPProxySource with the given config. func NewContourHTTPProxySource( ctx context.Context, dynamicKubeClient dynamic.Interface, cfg *Config, ) (Source, error) { tmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } // Use shared informer to listen for add/update/delete of HTTPProxys in the specified namespace. // Set resync period to 0, to prevent processing when nothing has changed. informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil) httpProxyInformer := informerFactory.ForResource(projectcontour.HTTPProxyGVR) // Add default resource event handlers to properly initialize informer. _, _ = httpProxyInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil { return nil, err } uc, err := NewUnstructuredConverter() if err != nil { return nil, fmt.Errorf("failed to setup Unstructured Converter: %w", err) } return &httpProxySource{ dynamicKubeClient: dynamicKubeClient, namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, fqdnTemplate: tmpl, combineFQDNAnnotation: cfg.CombineFQDNAndAnnotation, ignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, httpProxyInformer: httpProxyInformer, unstructuredConverter: uc, }, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all HTTPProxy resources in the source's namespace(s). func (sc *httpProxySource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { hps, err := sc.httpProxyInformer.Lister().ByNamespace(sc.namespace).List(labels.Everything()) if err != nil { return nil, err } var httpProxies []*projectcontour.HTTPProxy for _, hp := range hps { unstructuredHP, ok := hp.(*unstructured.Unstructured) if !ok { return nil, errors.New("could not convert") } hpConverted := &projectcontour.HTTPProxy{} err := sc.unstructuredConverter.scheme.Convert(unstructuredHP, hpConverted, nil) if err != nil { return nil, fmt.Errorf("failed to convert to HTTPProxy: %w", err) } httpProxies = append(httpProxies, hpConverted) } httpProxies, err = annotations.Filter(httpProxies, sc.annotationFilter) if err != nil { return nil, fmt.Errorf("failed to filter HTTPProxies: %w", err) } endpoints := []*endpoint.Endpoint{} for _, hp := range httpProxies { if annotations.IsControllerMismatch(hp, types.ContourHTTPProxy) { continue } hpEndpoints := sc.endpointsFromHTTPProxy(hp) // apply template if fqdn is missing on HTTPProxy hpEndpoints, err = fqdn.CombineWithTemplatedEndpoints( hpEndpoints, sc.fqdnTemplate, sc.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(hp) }, ) if err != nil { return nil, err } if endpoint.HasNoEmptyEndpoints(hpEndpoints, types.ContourHTTPProxy, hp) { continue } log.Debugf("Endpoints generated from HTTPProxy: %s/%s: %v", hp.Namespace, hp.Name, hpEndpoints) endpoints = append(endpoints, hpEndpoints...) } return MergeEndpoints(endpoints), nil } func (sc *httpProxySource) endpointsFromTemplate(httpProxy *projectcontour.HTTPProxy) ([]*endpoint.Endpoint, error) { hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, httpProxy) if err != nil { return nil, err } resource := fmt.Sprintf("HTTPProxy/%s/%s", httpProxy.Namespace, httpProxy.Name) ttl := annotations.TTLFromAnnotations(httpProxy.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(httpProxy.Annotations) if len(targets) == 0 { for _, lb := range httpProxy.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(httpProxy.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } // endpointsFromHTTPProxyConfig extracts the endpoints from a Contour HTTPProxy object func (sc *httpProxySource) endpointsFromHTTPProxy(httpProxy *projectcontour.HTTPProxy) []*endpoint.Endpoint { resource := fmt.Sprintf("HTTPProxy/%s/%s", httpProxy.Namespace, httpProxy.Name) ttl := annotations.TTLFromAnnotations(httpProxy.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(httpProxy.Annotations) if len(targets) == 0 { for _, lb := range httpProxy.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(httpProxy.Annotations) var endpoints []*endpoint.Endpoint if virtualHost := httpProxy.Spec.VirtualHost; virtualHost != nil { if fqdn := virtualHost.Fqdn; fqdn != "" { endpoints = append(endpoints, endpoint.EndpointsForHostname(fqdn, targets, ttl, providerSpecific, setIdentifier, resource)...) } } // Skip endpoints if we do not want entries from annotations if !sc.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(httpProxy.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } return endpoints } func (sc *httpProxySource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for httpproxy") // Right now there is no way to remove event handler from informer, see: // https://github.com/kubernetes/kubernetes/issues/79610 _, _ = sc.httpProxyInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } ================================================ FILE: source/contour_httpproxy_test.go ================================================ /* Copyright 2020 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 source import ( "context" "errors" "testing" fakeDynamic "k8s.io/client-go/dynamic/fake" projectcontour "github.com/projectcontour/contour/apis/projectcontour/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) // This is a compile-time validation that httpProxySource is a Source. var _ Source = &httpProxySource{} type HTTPProxySuite struct { suite.Suite source Source httpProxy *projectcontour.HTTPProxy } func newDynamicKubernetesClient() (*fakeDynamic.FakeDynamicClient, *runtime.Scheme) { s := runtime.NewScheme() _ = projectcontour.AddToScheme(s) return fakeDynamic.NewSimpleDynamicClient(s), s } type fakeLoadBalancerService struct { ips []string hostnames []string namespace string name string } func (ig fakeLoadBalancerService) Service() *v1.Service { svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Namespace: ig.namespace, Name: ig.name, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{}, }, }, } for _, ip := range ig.ips { svc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{ IP: ip, }) } for _, hostname := range ig.hostnames { svc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{ Hostname: hostname, }) } return svc } func (suite *HTTPProxySuite) SetupTest() { fakeDynamicClient, s := newDynamicKubernetesClient() var err error suite.source, err = NewContourHTTPProxySource( context.TODO(), fakeDynamicClient, &Config{ Namespace: "default", FQDNTemplate: "{{.Name}}", }, ) suite.NoError(err, "should initialize httpproxy source") suite.httpProxy = (fakeHTTPProxy{ name: "foo-httpproxy-with-targets", namespace: "default", host: "example.com", }).HTTPProxy() // Convert to unstructured unstructuredHTTPProxy, err := convertHTTPProxyToUnstructured(suite.httpProxy, s) if err != nil { suite.Error(err) } _, err = fakeDynamicClient.Resource(projectcontour.HTTPProxyGVR).Namespace(suite.httpProxy.Namespace).Create(context.Background(), unstructuredHTTPProxy, metav1.CreateOptions{}) suite.NoError(err, "should succeed") } func (suite *HTTPProxySuite) TestResourceLabelIsSet() { endpoints, _ := suite.source.Endpoints(context.Background()) for _, ep := range endpoints { suite.Equal("httpproxy/default/foo-httpproxy-with-targets", ep.Labels[endpoint.ResourceLabelKey], "should set correct resource label") } } func convertHTTPProxyToUnstructured(hp *projectcontour.HTTPProxy, s *runtime.Scheme) (*unstructured.Unstructured, error) { unstructuredHTTPProxy := &unstructured.Unstructured{} if err := s.Convert(hp, unstructuredHTTPProxy, context.Background()); err != nil { return nil, err } return unstructuredHTTPProxy, nil } func TestHTTPProxy(t *testing.T) { t.Parallel() suite.Run(t, new(HTTPProxySuite)) t.Run("endpointsFromHTTPProxy", testEndpointsFromHTTPProxy) t.Run("Endpoints", testHTTPProxyEndpoints) } func TestNewContourHTTPProxySource(t *testing.T) { t.Parallel() for _, ti := range []struct { title string annotationFilter string fqdnTemplate string combineFQDNAndAnnotation bool expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", combineFQDNAndAnnotation: true, }, { title: "non-empty annotation filter label", expectError: false, annotationFilter: "contour.heptio.com/ingress.class=contour", }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeDynamicClient, _ := newDynamicKubernetesClient() _, err := NewContourHTTPProxySource( t.Context(), fakeDynamicClient, &Config{ AnnotationFilter: ti.annotationFilter, FQDNTemplate: ti.fqdnTemplate, CombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation, }, ) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func testEndpointsFromHTTPProxy(t *testing.T) { t.Parallel() for _, ti := range []struct { title string httpProxy fakeHTTPProxy expected []*endpoint.Endpoint }{ { title: "one rule.host one lb.hostname", httpProxy: fakeHTTPProxy{ host: "foo.bar", // Kubernetes requires removal of trailing dot loadBalancer: fakeLoadBalancerService{ hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "one rule.host one lb.IP", httpProxy: fakeHTTPProxy{ host: "foo.bar", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "one rule.host two lb.IP and two lb.Hostname", httpProxy: fakeHTTPProxy{ host: "foo.bar", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8", "127.0.0.1"}, }, { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com", "alb.com"}, }, }, }, { title: "no rule.host", httpProxy: fakeHTTPProxy{}, expected: []*endpoint.Endpoint{}, }, { title: "no targets", httpProxy: fakeHTTPProxy{}, expected: []*endpoint.Endpoint{}, }, { title: "delegate httpproxy", httpProxy: fakeHTTPProxy{ delegate: true, }, expected: []*endpoint.Endpoint{}, }, { title: "provider-specific annotation is converted to endpoint property", httpProxy: fakeHTTPProxy{ host: "foo.bar", annotations: map[string]string{ annotations.AWSPrefix + "weight": "10", }, loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() source, err := newTestHTTPProxySource() require.NoError(t, err) endpoints := source.endpointsFromHTTPProxy(ti.httpProxy.HTTPProxy()) validateEndpoints(t, endpoints, ti.expected) }) } } func testHTTPProxyEndpoints(t *testing.T) { t.Parallel() namespace := "testing" for _, ti := range []struct { title string targetNamespace string annotationFilter string loadBalancer fakeLoadBalancerService httpProxyItems []fakeHTTPProxy expected []*endpoint.Endpoint expectError bool fqdnTemplate string combineFQDNAndAnnotation bool ignoreHostnameAnnotation bool }{ { title: "no httpproxy", targetNamespace: "", }, { title: "two simple httpproxys", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, host: "example.org", }, { name: "fake2", namespace: namespace, host: "new.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "two simple httpproxys on different namespaces", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: "testing1", host: "example.org", }, { name: "fake2", namespace: "testing2", host: "new.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "two simple httpproxys on different namespaces and a target namespace", targetNamespace: "testing1", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: "testing1", host: "example.org", }, { name: "fake2", namespace: "testing2", host: "new.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "valid matching annotation filter expression", targetNamespace: "", annotationFilter: "contour.heptio.com/ingress.class in (alb, contour)", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "contour.heptio.com/ingress.class": "contour", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "valid non-matching annotation filter expression", targetNamespace: "", annotationFilter: "contour.heptio.com/ingress.class in (alb, contour)", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "contour.heptio.com/ingress.class": "tectonic", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{}, }, { title: "invalid annotation filter expression", targetNamespace: "", annotationFilter: "contour.heptio.com/ingress.name in (a b)", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "contour.heptio.com/ingress.class": "alb", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{}, expectError: true, }, { title: "valid matching annotation filter label", targetNamespace: "", annotationFilter: "contour.heptio.com/ingress.class=contour", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "contour.heptio.com/ingress.class": "contour", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "valid non-matching annotation filter label", targetNamespace: "", annotationFilter: "contour.heptio.com/ingress.class=contour", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "contour.heptio.com/ingress.class": "alb", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{}, }, { title: "our controller type is dns-controller", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, host: "example.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "different controller types are ignored", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: "some-other-tool", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{}, }, { title: "template for httpproxy if host is missing", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, hostnames: []string{"elb.com"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, host: "", }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "fake1.ext-dns.test.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com"}, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "another controller annotation skipped even with template", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: "other-controller", }, host: "", }, }, expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "multiple FQDN template hostnames", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{}, host: "", }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "fake1.ext-dna.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", }, { title: "multiple FQDN template hostnames", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{}, host: "", }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "httpproxy-target.com", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "fake1.ext-dna.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example.org", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dns.test.com", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dna.test.com", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", combineFQDNAndAnnotation: true, }, { title: "httpproxy rules with annotation", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "httpproxy-target.com", }, host: "example.org", }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "httpproxy-target.com", }, host: "example2.org", }, { name: "fake3", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "1.2.3.4", }, host: "example3.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example2.org", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example3.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "httpproxy rules with hostname annotation", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"1.2.3.4"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "httpproxy rules with hostname annotation having multiple hostnames", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"1.2.3.4"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com, another-dns-through-hostname.com", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "another-dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "httpproxy rules with hostname and target annotation", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com", annotations.TargetKey: "httpproxy-target.com", }, host: "example.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, { title: "httpproxy rules with annotation and custom TTL", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "httpproxy-target.com", annotations.TtlKey: "6", }, host: "example.org", }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "httpproxy-target.com", annotations.TtlKey: "1", }, host: "example2.org", }, { name: "fake3", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "httpproxy-target.com", annotations.TtlKey: "10s", }, host: "example3.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"httpproxy-target.com"}, RecordTTL: endpoint.TTL(6), }, { DNSName: "example2.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"httpproxy-target.com"}, RecordTTL: endpoint.TTL(1), }, { DNSName: "example3.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"httpproxy-target.com"}, RecordTTL: endpoint.TTL(10), }, }, }, { title: "template for httpproxy with annotation", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{}, hostnames: []string{}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "httpproxy-target.com", }, host: "", }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "httpproxy-target.com", }, host: "", }, { name: "fake3", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "1.2.3.4", }, host: "", }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dns.test.com", Targets: endpoint.Targets{"httpproxy-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake3.ext-dns.test.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "httpproxy with empty annotation", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{}, hostnames: []string{}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "", }, host: "", }, }, expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "ignore hostname annotations", targetNamespace: "", loadBalancer: fakeLoadBalancerService{ ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, httpProxyItems: []fakeHTTPProxy{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "ignore.me", }, host: "example.org", }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "ignore.me.too", }, host: "new.org", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, ignoreHostnameAnnotation: true, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() httpProxies := make([]*projectcontour.HTTPProxy, 0) for _, item := range ti.httpProxyItems { item.loadBalancer = ti.loadBalancer httpProxies = append(httpProxies, item.HTTPProxy()) } fakeDynamicClient, scheme := newDynamicKubernetesClient() for _, httpProxy := range httpProxies { converted, err := convertHTTPProxyToUnstructured(httpProxy, scheme) require.NoError(t, err) _, err = fakeDynamicClient.Resource(projectcontour.HTTPProxyGVR).Namespace(httpProxy.Namespace).Create(t.Context(), converted, metav1.CreateOptions{}) require.NoError(t, err) } httpProxySource, err := NewContourHTTPProxySource( t.Context(), fakeDynamicClient, &Config{ Namespace: ti.targetNamespace, AnnotationFilter: ti.annotationFilter, FQDNTemplate: ti.fqdnTemplate, CombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation, IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, }, ) require.NoError(t, err) res, err := httpProxySource.Endpoints(t.Context()) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } validateEndpoints(t, res, ti.expected) }) } } // httpproxy specific helper functions func newTestHTTPProxySource() (*httpProxySource, error) { fakeDynamicClient, _ := newDynamicKubernetesClient() src, err := NewContourHTTPProxySource( context.TODO(), fakeDynamicClient, &Config{ FQDNTemplate: "{{.Name}}", }, ) if err != nil { return nil, err } irsrc, ok := src.(*httpProxySource) if !ok { return nil, errors.New("underlying source type was not httpproxy") } return irsrc, nil } type fakeHTTPProxy struct { namespace string name string annotations map[string]string host string delegate bool loadBalancer fakeLoadBalancerService } func (ir fakeHTTPProxy) HTTPProxy() *projectcontour.HTTPProxy { var spec projectcontour.HTTPProxySpec if ir.delegate { spec = projectcontour.HTTPProxySpec{} } else { spec = projectcontour.HTTPProxySpec{ VirtualHost: &projectcontour.VirtualHost{ Fqdn: ir.host, }, } } lb := v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{}, } for _, ip := range ir.loadBalancer.ips { lb.Ingress = append(lb.Ingress, v1.LoadBalancerIngress{ IP: ip, }) } for _, hostname := range ir.loadBalancer.hostnames { lb.Ingress = append(lb.Ingress, v1.LoadBalancerIngress{ Hostname: hostname, }) } httpProxy := &projectcontour.HTTPProxy{ ObjectMeta: metav1.ObjectMeta{ Namespace: ir.namespace, Name: ir.name, Annotations: ir.annotations, }, Spec: spec, Status: projectcontour.HTTPProxyStatus{ LoadBalancer: lb, }, } return httpProxy } ================================================ FILE: source/crd.go ================================================ /* Copyright 2018 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 source import ( "context" "fmt" "os" "strings" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/source/annotations" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "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" ) // crdSource is an implementation of Source that provides endpoints by listing // specified CRD and fetching Endpoints embedded in Spec. // // +externaldns:source:name=crd // +externaldns:source:category=ExternalDNS // +externaldns:source:description=Creates DNS entries from DNSEndpoint CRD resources // +externaldns:source:resources=DNSEndpoint.externaldns.k8s.io // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=false // +externaldns:source:events=true // +externaldns:source:provider-specific=true type crdSource struct { crdClient rest.Interface namespace string crdResource string codec runtime.ParameterCodec annotationFilter string labelSelector labels.Selector informer cache.SharedInformer } // NewCRDClientForAPIVersionKind return rest client for the given apiVersion and kind of the CRD func NewCRDClientForAPIVersionKind( client kubernetes.Interface, cfg *Config, ) (*rest.RESTClient, *runtime.Scheme, error) { kubeConfig := cfg.KubeConfig if kubeConfig == "" { if _, err := os.Stat(clientcmd.RecommendedHomeFile); err == nil { kubeConfig = clientcmd.RecommendedHomeFile } } // TODO: GetRestConfig logic is duplicated from store.go, refactor to avoid duplication config, err := clientcmd.BuildConfigFromFlags(cfg.APIServerURL, kubeConfig) if err != nil { return nil, nil, err } groupVersion, err := schema.ParseGroupVersion(cfg.CRDSourceAPIVersion) if err != nil { return nil, nil, err } apiResourceList, err := client.Discovery().ServerResourcesForGroupVersion(groupVersion.String()) if err != nil { return nil, 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 == cfg.CRDSourceKind { crdAPIResource = &apiResource break } } if crdAPIResource == nil { return nil, nil, fmt.Errorf("unable to find Resource Kind %q in GroupVersion %q", cfg.CRDSourceKind, cfg.CRDSourceAPIVersion) } scheme := runtime.NewScheme() _ = apiv1alpha1.AddToScheme(scheme) config.GroupVersion = &groupVersion config.APIPath = "/apis" config.NegotiatedSerializer = serializer.WithoutConversionCodecFactory{CodecFactory: serializer.NewCodecFactory(scheme)} crdClient, err := rest.UnversionedRESTClientFor(config) if err != nil { return nil, nil, err } return crdClient, scheme, nil } // NewCRDSource creates a new crdSource with the given config. func NewCRDSource( crdClient rest.Interface, cfg *Config, scheme *runtime.Scheme) (Source, error) { sourceCrd := crdSource{ crdResource: strings.ToLower(cfg.CRDSourceKind) + "s", namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, labelSelector: cfg.LabelFilter, crdClient: crdClient, codec: runtime.NewParameterCodec(scheme), } if cfg.UpdateEvents { // external-dns already runs its sync-handler periodically (controlled by `--interval` flag) to ensure any // missed or dropped events are handled. specify resync period 0 to avoid unnecessary sync handler invocations. sourceCrd.informer = cache.NewSharedInformer( &cache.ListWatch{ ListWithContextFunc: func(ctx context.Context, lo metav1.ListOptions) (runtime.Object, error) { return sourceCrd.List(ctx, &lo) }, WatchFuncWithContext: func(ctx context.Context, lo metav1.ListOptions) (watch.Interface, error) { return sourceCrd.watch(ctx, &lo) }, }, &apiv1alpha1.DNSEndpoint{}, 0) go sourceCrd.informer.Run(wait.NeverStop) } return &sourceCrd, nil } func (cs *crdSource) AddEventHandler(_ context.Context, handler func()) { if cs.informer != nil { log.Debug("Adding event handler for CRD") // Right now there is no way to remove event handler from informer, see: // https://github.com/kubernetes/kubernetes/issues/79610 _, _ = cs.informer.AddEventHandler( cache.ResourceEventHandlerFuncs{ AddFunc: func(_ any) { handler() }, UpdateFunc: func(_ any, _ any) { handler() }, DeleteFunc: func(_ any) { handler() }, }, ) } } // Endpoints returns endpoint objects. func (cs *crdSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { endpoints := []*endpoint.Endpoint{} var ( result *apiv1alpha1.DNSEndpointList err error ) result, err = cs.List(ctx, &metav1.ListOptions{LabelSelector: cs.labelSelector.String()}) if err != nil { return nil, err } itemPtrs := make([]*apiv1alpha1.DNSEndpoint, len(result.Items)) for i := range result.Items { itemPtrs[i] = &result.Items[i] } filtered, err := annotations.Filter(itemPtrs, cs.annotationFilter) if err != nil { return nil, err } for _, dnsEndpoint := range filtered { var crdEndpoints []*endpoint.Endpoint for _, ep := range dnsEndpoint.Spec.Endpoints { if (ep.RecordType == endpoint.RecordTypeCNAME || ep.RecordType == endpoint.RecordTypeA || ep.RecordType == endpoint.RecordTypeAAAA) && len(ep.Targets) < 1 { log.Debugf("Endpoint %s with DNSName %s has an empty list of targets, allowing it to pass through for default-targets processing", dnsEndpoint.Name, ep.DNSName) } illegalTarget := false for _, target := range ep.Targets { switch ep.RecordType { case endpoint.RecordTypeTXT, endpoint.RecordTypeMX: continue // TXT records allow arbitrary text, skip validation; MX records can have trailing dot but it's not required, skip validation case endpoint.RecordTypeCNAME: continue // RFC 1035 §5.1: trailing dot denotes an absolute FQDN in zone file notation; both forms are valid } hasDot := strings.HasSuffix(target, ".") switch ep.RecordType { case endpoint.RecordTypeNAPTR: illegalTarget = !hasDot // Must have trailing dot default: illegalTarget = hasDot // Must NOT have trailing dot } if illegalTarget { fixed := target + "." if ep.RecordType != endpoint.RecordTypeNAPTR { fixed = strings.TrimSuffix(target, ".") } log.Warnf("Endpoint %s/%s with DNSName %s has an illegal target %q for %s record — use %q not %q.", dnsEndpoint.Namespace, dnsEndpoint.Name, ep.DNSName, target, ep.RecordType, fixed, target) break } } if illegalTarget { continue } ep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf("crd/%s/%s", dnsEndpoint.Namespace, dnsEndpoint.Name)) crdEndpoints = append(crdEndpoints, ep) } endpoint.AttachRefObject(crdEndpoints, events.NewObjectReference(dnsEndpoint, types.CRD)) endpoints = append(endpoints, crdEndpoints...) if dnsEndpoint.Status.ObservedGeneration == dnsEndpoint.Generation { continue } dnsEndpoint.Status.ObservedGeneration = dnsEndpoint.Generation // Update the ObservedGeneration _, err = cs.UpdateStatus(ctx, dnsEndpoint) if err != nil { log.Warnf("Could not update ObservedGeneration of the CRD: %v", err) } } return MergeEndpoints(endpoints), nil } func (cs *crdSource) watch(ctx context.Context, opts *metav1.ListOptions) (watch.Interface, error) { opts.Watch = true return cs.crdClient.Get(). Namespace(cs.namespace). Resource(cs.crdResource). VersionedParams(opts, cs.codec). Watch(ctx) } func (cs *crdSource) List(ctx context.Context, opts *metav1.ListOptions) (*apiv1alpha1.DNSEndpointList, error) { result := &apiv1alpha1.DNSEndpointList{} return result, cs.crdClient.Get(). Namespace(cs.namespace). Resource(cs.crdResource). VersionedParams(opts, cs.codec). Do(ctx). Into(result) } func (cs *crdSource) UpdateStatus(ctx context.Context, dnsEndpoint *apiv1alpha1.DNSEndpoint) (*apiv1alpha1.DNSEndpoint, error) { result := &apiv1alpha1.DNSEndpoint{} return result, cs.crdClient.Put(). Namespace(dnsEndpoint.Namespace). Resource(cs.crdResource). Name(dnsEndpoint.Name). SubResource("status"). Body(dnsEndpoint). Do(ctx). Into(result) } ================================================ FILE: source/crd_test.go ================================================ /* Copyright 2018 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 source import ( "bytes" "encoding/json" "fmt" "io" "math/rand" "net/http" "strings" "sync/atomic" "testing" "time" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/client-go/rest" "k8s.io/client-go/rest/fake" "k8s.io/client-go/tools/cache" cachetesting "k8s.io/client-go/tools/cache/testing" "sigs.k8s.io/external-dns/source/types" log "github.com/sirupsen/logrus" k8stypes "k8s.io/apimachinery/pkg/types" apiv1alpha1 "sigs.k8s.io/external-dns/apis/v1alpha1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) type CRDSuite struct { suite.Suite } func (suite *CRDSuite) SetupTest() { } func defaultHeader() http.Header { header := http.Header{} header.Set("Content-Type", runtime.ContentTypeJSON) return header } func objBody(codec runtime.Encoder, obj runtime.Object) io.ReadCloser { return io.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, obj)))) } func fakeRESTClient(endpoints []*endpoint.Endpoint, apiVersion, kind, namespace, name string, annotations map[string]string, labels map[string]string, _ *testing.T) rest.Interface { groupVersion, _ := schema.ParseGroupVersion(apiVersion) scheme := runtime.NewScheme() _ = apiv1alpha1.AddToScheme(scheme) dnsEndpointList := apiv1alpha1.DNSEndpointList{} dnsEndpoint := &apiv1alpha1.DNSEndpoint{ TypeMeta: metav1.TypeMeta{ APIVersion: apiVersion, Kind: kind, }, ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, Annotations: annotations, Labels: labels, Generation: 1, }, Spec: apiv1alpha1.DNSEndpointSpec{ Endpoints: endpoints, }, } codecFactory := serializer.WithoutConversionCodecFactory{ CodecFactory: serializer.NewCodecFactory(scheme), } client := &fake.RESTClient{ GroupVersion: groupVersion, VersionedAPIPath: "/apis/" + apiVersion, NegotiatedSerializer: codecFactory, Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { codec := codecFactory.LegacyCodec(groupVersion) switch p, m := req.URL.Path, req.Method; { case p == "/apis/"+apiVersion+"/"+strings.ToLower(kind)+"s" && m == http.MethodGet: fallthrough case p == "/apis/"+apiVersion+"/namespaces/"+namespace+"/"+strings.ToLower(kind)+"s" && m == http.MethodGet: dnsEndpointList.Items = dnsEndpointList.Items[:0] dnsEndpointList.Items = append(dnsEndpointList.Items, *dnsEndpoint) return &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, &dnsEndpointList)}, nil case strings.HasPrefix(p, "/apis/"+apiVersion+"/namespaces/") && strings.HasSuffix(p, strings.ToLower(kind)+"s") && m == http.MethodGet: return &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, &dnsEndpointList)}, nil case p == "/apis/"+apiVersion+"/namespaces/"+namespace+"/"+strings.ToLower(kind)+"s/"+name+"/status" && m == http.MethodPut: decoder := json.NewDecoder(req.Body) var body apiv1alpha1.DNSEndpoint err := decoder.Decode(&body) if err != nil { return nil, err } dnsEndpoint.Status.ObservedGeneration = body.Status.ObservedGeneration return &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, dnsEndpoint)}, nil default: return nil, fmt.Errorf("unexpected request: %#v\n%#v", req.URL, req) } }), } return client } func TestCRDSource(t *testing.T) { suite.Run(t, new(CRDSuite)) t.Run("Interface", testCRDSourceImplementsSource) t.Run("Endpoints", testCRDSourceEndpoints) } // testCRDSourceImplementsSource tests that crdSource is a valid Source. func testCRDSourceImplementsSource(t *testing.T) { require.Implements(t, (*Source)(nil), new(crdSource)) } // testCRDSourceEndpoints tests various scenarios of using CRD source. func testCRDSourceEndpoints(t *testing.T) { for _, ti := range []struct { title string registeredNamespace string namespace string registeredAPIVersion string apiVersion string registeredKind string kind string endpoints []*endpoint.Endpoint expectEndpoints bool expectError bool annotationFilter string labelFilter string annotations map[string]string labels map[string]string }{ { title: "invalid crd api version", registeredAPIVersion: "test.k8s.io/v1alpha1", apiVersion: "blah.k8s.io/v1alpha1", registeredKind: "DNSEndpoint", kind: "DNSEndpoint", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: false, expectError: true, }, { title: "invalid crd kind", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: "JustEndpoint", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: false, expectError: true, }, { title: "endpoints within a specific namespace", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "no endpoints within a specific namespace", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "bar", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: false, expectError: false, }, { title: "valid crd with no targets (relies on default-targets)", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", endpoints: []*endpoint.Endpoint{ { DNSName: "no-targets.example.org", Targets: endpoint.Targets{}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "valid crd gvk with single endpoint", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "valid crd gvk with multiple endpoints", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, { DNSName: "xyz.example.org", Targets: endpoint.Targets{"abc.example.org"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "valid crd gvk with annotation and non matching annotation filter", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", annotations: map[string]string{"test": "that"}, annotationFilter: "test=filter_something_else", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: false, expectError: false, }, { title: "valid crd gvk with annotation and matching annotation filter", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", annotations: map[string]string{"test": "that"}, annotationFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "valid crd gvk with label and non matching label filter", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=filter_something_else", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: false, expectError: false, }, { title: "valid crd gvk with label and matching label filter", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "Create NS record", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "abc.example.org", Targets: endpoint.Targets{"ns1.k8s.io", "ns2.k8s.io"}, RecordType: endpoint.RecordTypeNS, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "Create SRV record", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "_svc._tcp.example.org", Targets: endpoint.Targets{"0 0 80 abc.example.org", "0 0 80 def.example.org"}, RecordType: endpoint.RecordTypeSRV, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "Create NAPTR record", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{`100 10 "S" "SIP+D2U" "!^.*$!sip:customer-service@example.org!" _sip._udp.example.org.`, `102 10 "S" "SIP+D2T" "!^.*$!sip:customer-service@example.org!" _sip._tcp.example.org.`}, RecordType: endpoint.RecordTypeNAPTR, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "CNAME target with trailing dot (RFC 1035 §5.1 absolute FQDN) is valid", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"foo.example.org."}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 180, }, }, expectEndpoints: true, }, { title: "CNAME target without trailing dot (relative name)", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "internal.example.com", Targets: endpoint.Targets{"backend.cluster.local"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 300, }, }, expectEndpoints: true, }, { title: "illegal target NAPTR", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{`100 10 "S" "SIP+D2U" "!^.*$!sip:customer-service@example.org!" _sip._udp.example.org`, `102 10 "S" "SIP+D2T" "!^.*$!sip:customer-service@example.org!" _sip._tcp.example.org`}, RecordType: endpoint.RecordTypeNAPTR, RecordTTL: 180, }, }, expectEndpoints: false, expectError: false, }, { title: "valid target TXT", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"foo.example.org."}, RecordType: endpoint.RecordTypeTXT, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "illegal target A", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4."}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, expectEndpoints: false, expectError: false, }, { title: "MX Record allowing trailing dot in target", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"example.com."}, RecordType: endpoint.RecordTypeMX, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "MX Record without trailing dot in target", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", labels: map[string]string{"test": "that"}, labelFilter: "test=that", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"example.com"}, RecordType: endpoint.RecordTypeMX, RecordTTL: 180, }, }, expectEndpoints: true, expectError: false, }, { title: "provider-specific properties are passed through from DNSEndpoint spec", registeredAPIVersion: apiv1alpha1.GroupVersion.String(), apiVersion: apiv1alpha1.GroupVersion.String(), registeredKind: apiv1alpha1.DNSEndpointKind, kind: apiv1alpha1.DNSEndpointKind, namespace: "foo", registeredNamespace: "foo", endpoints: []*endpoint.Endpoint{ { DNSName: "subdomain.example.org", Targets: endpoint.Targets{"other.example.org"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 180, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/failover", Value: "PRIMARY"}, {Name: "aws/health-check-id", Value: "asdf1234-as12-as12-as12-asdf12345678"}, {Name: "aws/evaluate-target-health", Value: "true"}, }, SetIdentifier: "some-unique-id", }, }, expectEndpoints: true, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() restClient := fakeRESTClient(ti.endpoints, ti.registeredAPIVersion, ti.registeredKind, ti.registeredNamespace, "test", ti.annotations, ti.labels, t) groupVersion, err := schema.ParseGroupVersion(ti.apiVersion) require.NoError(t, err) require.NotNil(t, groupVersion) scheme := runtime.NewScheme() err = apiv1alpha1.AddToScheme(scheme) require.NoError(t, err) labelSelector, err := labels.Parse(ti.labelFilter) require.NoError(t, err) // At present, client-go's fake.RESTClient (used by crd_test.go) is known to cause race conditions when used // with informers: https://github.com/kubernetes/kubernetes/issues/95372 // So don't start the informer during testing. cs, err := NewCRDSource(restClient, &Config{ Namespace: ti.namespace, AnnotationFilter: ti.annotationFilter, LabelFilter: labelSelector, CRDSourceKind: ti.kind, UpdateEvents: false, }, scheme) require.NoError(t, err) receivedEndpoints, err := cs.Endpoints(t.Context()) if ti.expectError { require.Errorf(t, err, "Received err %v", err) } else { require.NoErrorf(t, err, "Received err %v", err) } if len(receivedEndpoints) == 0 && !ti.expectEndpoints { return } if err == nil { validateCRDResource(t, cs, ti.expectError) } // Validate received endpoints against expected endpoints. validateEndpoints(t, receivedEndpoints, ti.endpoints) for _, e := range receivedEndpoints { // TODO: at the moment not all sources apply ResourceLabelKey require.GreaterOrEqual(t, len(e.Labels), 1, "endpoint must have at least one label") require.Contains(t, e.Labels, endpoint.ResourceLabelKey, "endpoint must include the ResourceLabelKey label") } }) } } func TestCRDSourceIllegalTargetWarnings(t *testing.T) { for _, ti := range []struct { title string endpoints []*endpoint.Endpoint wantWarning string }{ { title: "A record with trailing dot warns with fix suggestion", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4."}, RecordType: endpoint.RecordTypeA, RecordTTL: 180, }, }, wantWarning: `illegal target "1.2.3.4." for A record — use "1.2.3.4" not "1.2.3.4."`, }, { title: "NAPTR record without trailing dot warns with fix suggestion", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"_sip._udp.example.org"}, RecordType: endpoint.RecordTypeNAPTR, RecordTTL: 180, }, }, wantWarning: `illegal target "_sip._udp.example.org" for NAPTR record — use "_sip._udp.example.org." not "_sip._udp.example.org"`, }, { title: "CNAME with empty targets produces no warning", endpoints: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 180, }, }, wantWarning: ``, }, } { t.Run(ti.title, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) restClient := fakeRESTClient(ti.endpoints, apiv1alpha1.GroupVersion.String(), apiv1alpha1.DNSEndpointKind, "foo", "test", nil, nil, t) scheme := runtime.NewScheme() require.NoError(t, apiv1alpha1.AddToScheme(scheme)) cs, err := NewCRDSource(restClient, &Config{ Namespace: "foo", AnnotationFilter: "", LabelFilter: labels.Everything(), CRDSourceKind: apiv1alpha1.DNSEndpointKind, UpdateEvents: false, }, scheme) require.NoError(t, err) _, err = cs.Endpoints(t.Context()) require.NoError(t, err) if ti.wantWarning == "" { require.Empty(t, hook.Entries, "expected no warnings to be logged") } else { logtest.TestHelperLogContainsWithLogLevel(ti.wantWarning, log.WarnLevel, hook, t) } }) } } func TestCRDSource_NoInformer(t *testing.T) { cs := &crdSource{informer: nil} called := false cs.AddEventHandler(t.Context(), func() { called = true }) require.False(t, called, "handler must not be called when informer is nil") } func TestCRDSource_AddEventHandler_Add(t *testing.T) { ctx := t.Context() watcher, cs := helperCreateWatcherWithInformer(t) var counter atomic.Int32 cs.AddEventHandler(ctx, func() { counter.Add(1) }) obj := &unstructured.Unstructured{} obj.SetName("test") watcher.Add(obj) require.Eventually(t, func() bool { return counter.Load() == 1 }, 2*time.Second, 10*time.Millisecond) } func TestCRDSource_AddEventHandler_Update(t *testing.T) { ctx := t.Context() watcher, cs := helperCreateWatcherWithInformer(t) var counter atomic.Int32 cs.AddEventHandler(ctx, func() { counter.Add(1) }) obj := unstructured.Unstructured{} obj.SetName("test") obj.SetNamespace("default") obj.SetUID("9be5b64e-3ee9-11f0-88ee-1eb95c6fd730") watcher.Add(&obj) require.Eventually(t, func() bool { return len(watcher.Items) == 1 }, 2*time.Second, 10*time.Millisecond) modified := obj.DeepCopy() modified.SetLabels(map[string]string{"new-label": "this"}) watcher.Modify(modified) require.Eventually(t, func() bool { return len(watcher.Items) == 1 }, 2*time.Second, 10*time.Millisecond) require.Eventually(t, func() bool { return counter.Load() == 2 }, 2*time.Second, 10*time.Millisecond) } func TestCRDSource_AddEventHandler_Delete(t *testing.T) { ctx := t.Context() watcher, cs := helperCreateWatcherWithInformer(t) var counter atomic.Int32 cs.AddEventHandler(ctx, func() { counter.Add(1) }) obj := &unstructured.Unstructured{} obj.SetName("test") watcher.Delete(obj) require.Eventually(t, func() bool { return counter.Load() == 1 }, 2*time.Second, 10*time.Millisecond) } func TestCRDSource_Watch(t *testing.T) { scheme := runtime.NewScheme() err := apiv1alpha1.AddToScheme(scheme) require.NoError(t, err) var watchCalled bool codecFactory := serializer.WithoutConversionCodecFactory{ CodecFactory: serializer.NewCodecFactory(scheme), } versionApiPath := fmt.Sprintf("/apis/%s", apiv1alpha1.GroupVersion.String()) client := &fake.RESTClient{ GroupVersion: apiv1alpha1.GroupVersion, VersionedAPIPath: versionApiPath, NegotiatedSerializer: codecFactory, Client: fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) { if req.URL.Path == fmt.Sprintf("%s/namespaces/test-ns/dnsendpoints", versionApiPath) && req.URL.Query().Get("watch") == "true" { watchCalled = true return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), }, nil } t.Errorf("unexpected request: %v", req.URL) return nil, fmt.Errorf("unexpected request: %v", req.URL) }), } cs := &crdSource{ crdClient: client, namespace: "test-ns", crdResource: "dnsendpoints", codec: runtime.NewParameterCodec(scheme), } opts := &metav1.ListOptions{} _, err = cs.watch(t.Context(), opts) require.NoError(t, err) require.True(t, watchCalled) require.True(t, opts.Watch) } func validateCRDResource(t *testing.T, src Source, expectError bool) { t.Helper() cs := src.(*crdSource) result, err := cs.List(t.Context(), &metav1.ListOptions{}) if expectError { require.Errorf(t, err, "Received err %v", err) } else { require.NoErrorf(t, err, "Received err %v", err) } for _, dnsEndpoint := range result.Items { if dnsEndpoint.Status.ObservedGeneration != dnsEndpoint.Generation { require.Errorf(t, err, "Unexpected CRD resource result: ObservedGenerations <%v> is not equal to Generation<%v>", dnsEndpoint.Status.ObservedGeneration, dnsEndpoint.Generation) } } } func TestDNSEndpointsWithSetResourceLabels(t *testing.T) { typeCounts := map[string]int{ endpoint.RecordTypeA: 3, endpoint.RecordTypeCNAME: 2, endpoint.RecordTypeNS: 7, endpoint.RecordTypeNAPTR: 1, } crds := generateTestFixtureDNSEndpointsByType("test-ns", typeCounts) for _, crd := range crds.Items { for _, ep := range crd.Spec.Endpoints { require.Empty(t, ep.Labels, "endpoint not have labels set") require.NotContains(t, ep.Labels, endpoint.ResourceLabelKey, "endpoint must not include the ResourceLabelKey label") } } scheme := runtime.NewScheme() err := apiv1alpha1.AddToScheme(scheme) require.NoError(t, err) codecFactory := serializer.WithoutConversionCodecFactory{ CodecFactory: serializer.NewCodecFactory(scheme), } client := &fake.RESTClient{ GroupVersion: apiv1alpha1.GroupVersion, VersionedAPIPath: fmt.Sprintf("/apis/%s", apiv1alpha1.GroupVersion.String()), NegotiatedSerializer: codecFactory, Client: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: objBody(codecFactory.LegacyCodec(apiv1alpha1.GroupVersion), &crds), }, nil }), } cs := &crdSource{ crdClient: client, namespace: "test-ns", crdResource: "dnsendpoints", codec: runtime.NewParameterCodec(scheme), labelSelector: labels.Everything(), } res, err := cs.Endpoints(t.Context()) require.NoError(t, err) for _, ep := range res { require.Contains(t, ep.Labels, endpoint.ResourceLabelKey) } } func TestProcessEndpoint_CRD_RefObjectExist(t *testing.T) { typeCounts := map[string]int{ endpoint.RecordTypeA: 2, endpoint.RecordTypeAAAA: 3, } elements := generateTestFixtureDNSEndpointsByType("test-ns", typeCounts) scheme := runtime.NewScheme() err := apiv1alpha1.AddToScheme(scheme) require.NoError(t, err) codecFactory := serializer.WithoutConversionCodecFactory{ CodecFactory: serializer.NewCodecFactory(scheme), } // TODO: reduce duplication and move to pkg/client/fakes client := &fake.RESTClient{ GroupVersion: apiv1alpha1.GroupVersion, VersionedAPIPath: fmt.Sprintf("/apis/%s", apiv1alpha1.GroupVersion.String()), NegotiatedSerializer: codecFactory, Client: fake.CreateHTTPClient(func(_ *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: objBody(codecFactory.LegacyCodec(apiv1alpha1.GroupVersion), &elements), }, nil }), } cs := &crdSource{ crdClient: client, namespace: "test-ns", crdResource: "dnsendpoints", codec: runtime.NewParameterCodec(scheme), labelSelector: labels.Everything(), } endpoints, err := cs.Endpoints(t.Context()) require.NoError(t, err) testutils.AssertEndpointsHaveRefObject(t, endpoints, types.CRD, len(elements.Items)) } func helperCreateWatcherWithInformer(t *testing.T) (*cachetesting.FakeControllerSource, crdSource) { t.Helper() ctx := t.Context() watcher := cachetesting.NewFakeControllerSource() informer := cache.NewSharedInformer(watcher, &unstructured.Unstructured{}, 0) go informer.RunWithContext(ctx) require.Eventually(t, func() bool { return cache.WaitForCacheSync(ctx.Done(), informer.HasSynced) }, 2*time.Second, 10*time.Millisecond) cs := &crdSource{ informer: informer, } return watcher, *cs } // generateTestFixtureDNSEndpointsByType generates DNSEndpoint CRDs according to the provided counts per RecordType. func generateTestFixtureDNSEndpointsByType(namespace string, typeCounts map[string]int) apiv1alpha1.DNSEndpointList { var result []apiv1alpha1.DNSEndpoint idx := 0 for rt, count := range typeCounts { for range count { result = append(result, apiv1alpha1.DNSEndpoint{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("dnsendpoint-%s-%d", rt, idx), Namespace: namespace, UID: k8stypes.UID(fmt.Sprintf("uid-%d", idx)), }, Spec: apiv1alpha1.DNSEndpointSpec{ Endpoints: []*endpoint.Endpoint{ { DNSName: strings.ToLower(fmt.Sprintf("%s-%d.example.com", rt, idx)), RecordType: rt, Targets: endpoint.Targets{fmt.Sprintf("192.0.2.%d", idx)}, RecordTTL: 300, }, }, }, }) idx++ } } // Shuffle the result to ensure randomness in the order. rand.New(rand.NewSource(time.Now().UnixNano())) rand.Shuffle(len(result), func(i, j int) { result[i], result[j] = result[j], result[i] }) return apiv1alpha1.DNSEndpointList{ Items: result, } } ================================================ FILE: source/empty.go ================================================ /* Copyright 2019 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 source import ( "context" "sigs.k8s.io/external-dns/endpoint" ) // emptySource is a Source that returns no endpoints. // // +externaldns:source:name=empty // +externaldns:source:category=Testing // +externaldns:source:description=Returns no endpoints (used for testing or as a placeholder) // +externaldns:source:resources=None // +externaldns:source:filters= // +externaldns:source:namespace= // +externaldns:source:fqdn-template=false // +externaldns:source:provider-specific=false type emptySource struct{} func (e *emptySource) AddEventHandler(_ context.Context, _ func()) { } // Endpoints collects endpoints of all nested Sources and returns them in a single slice. func (e *emptySource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { return []*endpoint.Endpoint{}, nil } // NewEmptySource creates a new emptySource. func NewEmptySource() Source { return &emptySource{} } ================================================ FILE: source/empty_test.go ================================================ /* Copyright 2019 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 source import ( "testing" ) func TestEmptySourceReturnsEmpty(t *testing.T) { e := NewEmptySource() endpoints, err := e.Endpoints(t.Context()) if err != nil { t.Errorf("Expected no error but got %s", err.Error()) } count := len(endpoints) if count != 0 { t.Errorf("Expected 0 endpoints but got %d", count) } } ================================================ FILE: source/endpoint_benchmark_test.go ================================================ /* 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 source import ( "context" "encoding/binary" "fmt" "math/rand/v2" "net" "strconv" "testing" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/source/informers" v1alpha3 "istio.io/api/networking/v1alpha3" istiov1a "istio.io/client-go/pkg/apis/networking/v1" "k8s.io/client-go/tools/cache" ) func BenchmarkEndpointTargetsFromServicesMedium(b *testing.B) { svcInformer, err := svcInformerWithServices(36, 1000) assert.NoError(b, err) sel := map[string]string{"app": "nginx", "env": "prod"} for b.Loop() { targets, _ := EndpointTargetsFromServices(svcInformer, "default", sel) assert.Equal(b, 36, targets.Len()) } } func BenchmarkEndpointTargetsFromServicesMediumIterateOverGateways(b *testing.B) { svcInformer, err := svcInformerWithServices(36, 500) assert.NoError(b, err) gateways := fixturesIstioGatewaySvcWithLabels(15, 70) for b.Loop() { for _, gateway := range gateways { _, _ = EndpointTargetsFromServices(svcInformer, gateway.Namespace, gateway.Spec.Selector) } } } func BenchmarkEndpointTargetsFromServicesHigh(b *testing.B) { svcInformer, err := svcInformerWithServices(36, 40000) assert.NoError(b, err) sel := map[string]string{"app": "nginx", "env": "prod"} for b.Loop() { targets, _ := EndpointTargetsFromServices(svcInformer, "default", sel) assert.Equal(b, 36, targets.Len()) } } // This benchmark tests the performance of EndpointTargetsFromServices with a high number of services and gateways. func BenchmarkEndpointTargetsFromServicesHighIterateOverGateways(b *testing.B) { svcInformer, err := svcInformerWithServices(36, 40000) assert.NoError(b, err) gateways := fixturesIstioGatewaySvcWithLabels(50, 1000) for b.Loop() { for _, gateway := range gateways { _, _ = EndpointTargetsFromServices(svcInformer, gateway.Namespace, gateway.Spec.Selector) } } } // helperToPopulateFakeClientWithServices populates a fake Kubernetes client with a specified services. func svcInformerWithServices(toLookup, underTest int) (coreinformers.ServiceInformer, error) { client := fake.NewClientset() informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(client, 0, kubeinformers.WithNamespace("default")) svcInformer := informerFactory.Core().V1().Services() ctx := context.Background() _, err := svcInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) if err != nil { return nil, fmt.Errorf("failed to add event handler: %w", err) } services := fixturesSvcWithLabels(toLookup, underTest) for _, svc := range services { _, err := client.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{}) if err != nil { return nil, fmt.Errorf("failed to create service %s: %w", svc.Name, err) } } stopCh := make(chan struct{}) defer close(stopCh) informerFactory.Start(stopCh) cache.WaitForCacheSync(stopCh, svcInformer.Informer().HasSynced) return svcInformer, nil } // fixturesSvcWithLabels creates a list of Services for testing purposes. // It generates a specified number of services with static labels and random labels. // The first `toLookup` services have specific labels, while the next `underTest` services have random labels. func fixturesSvcWithLabels(toLookup, underTest int) []*corev1.Service { var services []*corev1.Service var randomLabels = func(input int) map[string]string { if input%3 == 0 { // every third service has no labels return map[string]string{} } return map[string]string{ "app": fmt.Sprintf("service-%d", rand.IntN(100)), fmt.Sprintf("key%d", rand.IntN(100)): fmt.Sprintf("value%d", rand.IntN(100)), } } var randomIPs = func() []string { ip := rand.Uint32() buf := make([]byte, 4) binary.LittleEndian.PutUint32(buf, ip) return []string{net.IP(buf).String()} } var createService = func(name string, namespace string, selector map[string]string) *corev1.Service { return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, Spec: corev1.ServiceSpec{ Selector: selector, ExternalIPs: randomIPs(), }, } } // services with specific labels for i := range toLookup { svc := createService("nginx-svc-"+strconv.Itoa(i), "default", map[string]string{"app": "nginx", "env": "prod"}) services = append(services, svc) } // services with random labels for i := range underTest { svc := createService("random-svc-"+strconv.Itoa(i), "default", randomLabels(i)) services = append(services, svc) } // Shuffle the services to ensure randomness for range 3 { rand.Shuffle(len(services), func(i, j int) { services[i], services[j] = services[j], services[i] }) } return services } // fixturesIstioGatewaySvcWithLabels creates a list of Services for testing purposes. // It generates a specified number of gateways with static labels and random labels. // The first `toLookup` services have specific labels, while the next `underTest` services have random labels. func fixturesIstioGatewaySvcWithLabels(toLookup, underTest int) []*istiov1a.Gateway { var result []*istiov1a.Gateway var randomLabels = func(input int) map[string]string { if input%3 == 0 { // every third service has no labels return map[string]string{} } return map[string]string{ "app": fmt.Sprintf("service-%d", rand.IntN(100)), fmt.Sprintf("key%d", rand.IntN(100)): fmt.Sprintf("value%d", rand.IntN(100)), } } var createGateway = func(name string, namespace string, selector map[string]string) *istiov1a.Gateway { return &istiov1a.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, }, Spec: v1alpha3.Gateway{ Selector: selector, Servers: []*v1alpha3.Server{ { Port: &v1alpha3.Port{}, Hosts: []string{"*"}, }, }, }, } } // services with specific labels for i := range toLookup { svc := createGateway("istio-gw-"+strconv.Itoa(i), "default", map[string]string{"app": "nginx", "env": "prod"}) result = append(result, svc) } // services with random labels for i := range underTest { svc := createGateway("istio-random-svc-"+strconv.Itoa(i), "default", randomLabels(i)) result = append(result, svc) } // Shuffle the services to ensure randomness for range 3 { rand.Shuffle(len(result), func(i, j int) { result[i], result[j] = result[j], result[i] }) } return result } ================================================ FILE: source/endpoints.go ================================================ /* 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 source import ( "fmt" "k8s.io/apimachinery/pkg/labels" coreinformers "k8s.io/client-go/informers/core/v1" "sigs.k8s.io/external-dns/endpoint" ) // EndpointTargetsFromServices retrieves endpoint targets from services in a given namespace // that match the specified selector. It returns external IPs or load balancer addresses. // // TODO: add support for service.Spec.Ports (type NodePort) and service.Spec.ClusterIPs (type ClusterIP) func EndpointTargetsFromServices(svcInformer coreinformers.ServiceInformer, namespace string, selector map[string]string) (endpoint.Targets, error) { targets := endpoint.Targets{} services, err := svcInformer.Lister().Services(namespace).List(labels.Everything()) if err != nil { return nil, fmt.Errorf("failed to list labels for services in namespace %q: %w", namespace, err) } for _, service := range services { if !MatchesServiceSelector(selector, service.Spec.Selector) { continue } if len(service.Spec.ExternalIPs) > 0 { targets = append(targets, service.Spec.ExternalIPs...) continue } for _, lb := range service.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } else if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } } return endpoint.NewTargets(targets...), nil } ================================================ FILE: source/endpoints_test.go ================================================ /* 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 source import ( "testing" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" ) func TestEndpointTargetsFromServices(t *testing.T) { tests := []struct { name string services []*corev1.Service namespace string selector map[string]string expected endpoint.Targets wantErr bool }{ { name: "no services", services: []*corev1.Service{}, namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{}, }, { name: "matching service with external IPs", services: []*corev1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "svc1", Namespace: "default", }, Spec: corev1.ServiceSpec{ Selector: map[string]string{"app": "nginx"}, ExternalIPs: []string{"192.0.2.1", "158.123.32.23"}, }, }, }, namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{"158.123.32.23", "192.0.2.1"}, }, { name: "matching service with duplicate external IPs", services: []*corev1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "svc1", Namespace: "default", }, Spec: corev1.ServiceSpec{ Selector: map[string]string{"app": "nginx"}, ExternalIPs: []string{"192.0.2.1", "192.0.2.1", "158.123.32.23"}, }, }, }, namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{"158.123.32.23", "192.0.2.1"}, }, { name: "no matching service as service without selector", services: []*corev1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "svc1", Namespace: "default", }, Spec: corev1.ServiceSpec{ ExternalIPs: []string{"192.0.2.1"}, }, }, }, namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{}, }, { name: "matching service with load balancer IP", services: []*corev1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "svc2", Namespace: "default", }, Spec: corev1.ServiceSpec{ Selector: map[string]string{"app": "nginx"}, }, Status: corev1.ServiceStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ {IP: "192.0.2.2"}, }, }, }, }, }, namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{"192.0.2.2"}, }, { name: "matching service with load balancer hostname", services: []*corev1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "svc3", Namespace: "default", }, Spec: corev1.ServiceSpec{ Selector: map[string]string{"app": "nginx"}, }, Status: corev1.ServiceStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ {Hostname: "lb.example.com"}, }, }, }, }, }, namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{"lb.example.com"}, }, { name: "no matching services", services: []*corev1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "svc4", Namespace: "default", }, Spec: corev1.ServiceSpec{ Selector: map[string]string{"app": "apache"}, }, }, }, namespace: "default", selector: map[string]string{"app": "nginx"}, expected: endpoint.Targets{}, }, { name: "multiple selectors", services: []*corev1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "fake", Namespace: "default", }, Spec: corev1.ServiceSpec{ Selector: map[string]string{"app": "apache", "version": "v1"}, ExternalIPs: []string{"158.123.32.23"}, }, }, }, namespace: "default", selector: map[string]string{"version": "v1"}, expected: endpoint.Targets{"158.123.32.23"}, }, { name: "complex selectors", services: []*corev1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "fake", Namespace: "default", }, Spec: corev1.ServiceSpec{ Selector: map[string]string{ "app": "demo", "env": "prod", "team": "devops", "version": "v1", "release": "stable", "track": "daily", "tier": "backend", }, ExternalIPs: []string{"158.123.32.23"}, }, }, }, namespace: "default", selector: map[string]string{ "version": "v1", "release": "stable", "tier": "backend", "app": "demo", }, expected: endpoint.Targets{"158.123.32.23"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { client := fake.NewClientset() informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(client, 0, kubeinformers.WithNamespace(tt.namespace)) serviceInformer := informerFactory.Core().V1().Services() for _, svc := range tt.services { _, err := client.CoreV1().Services(tt.namespace).Create(t.Context(), svc, metav1.CreateOptions{}) assert.NoError(t, err) err = serviceInformer.Informer().GetIndexer().Add(svc) assert.NoError(t, err) } result, err := EndpointTargetsFromServices(serviceInformer, tt.namespace, tt.selector) if tt.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.expected, result) } }) } } func TestEndpointTargetsFromServicesWithFixtures(t *testing.T) { svcInformer, err := svcInformerWithServices(2, 9) assert.NoError(t, err) sel := map[string]string{"app": "nginx", "env": "prod"} targets, err := EndpointTargetsFromServices(svcInformer, "default", sel) assert.NoError(t, err) assert.Equal(t, 2, targets.Len()) } ================================================ FILE: source/f5_transportserver.go ================================================ /* Copyright 2022 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 source import ( "context" "errors" "fmt" "strings" f5 "github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1" log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/external-dns/source/informers" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) var f5TransportServerGVR = schema.GroupVersionResource{ Group: "cis.f5.com", Version: "v1", Resource: "transportservers", } // transportServerSource is an implementation of Source for F5 TransportServer objects. // // +externaldns:source:name=f5-transportserver // +externaldns:source:category=Load Balancers // +externaldns:source:description=Creates DNS entries from F5 TransportServer resources // +externaldns:source:resources=TransportServer.cis.f5.com // +externaldns:source:filters=annotation // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=false // +externaldns:source:provider-specific=false type f5TransportServerSource struct { dynamicKubeClient dynamic.Interface transportServerInformer kubeinformers.GenericInformer kubeClient kubernetes.Interface annotationFilter string namespace string unstructuredConverter *unstructuredConverter } func NewF5TransportServerSource( ctx context.Context, dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, cfg *Config, ) (Source, error) { informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil) transportServerInformer := informerFactory.ForResource(f5TransportServerGVR) _, _ = transportServerInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil { return nil, err } uc, err := newTSUnstructuredConverter() if err != nil { return nil, fmt.Errorf("failed to setup unstructured converter: %w", err) } return &f5TransportServerSource{ dynamicKubeClient: dynamicKubeClient, transportServerInformer: transportServerInformer, kubeClient: kubeClient, namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, unstructuredConverter: uc, }, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all TransportServers in the source's namespace(s). func (ts *f5TransportServerSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { transportServerObjects, err := ts.transportServerInformer.Lister().ByNamespace(ts.namespace).List(labels.Everything()) if err != nil { return nil, err } var transportServers []*f5.TransportServer for _, tsObj := range transportServerObjects { unstructuredHost, ok := tsObj.(*unstructured.Unstructured) if !ok { return nil, errors.New("could not convert") } transportServer := &f5.TransportServer{} err := ts.unstructuredConverter.scheme.Convert(unstructuredHost, transportServer, nil) if err != nil { return nil, err } transportServers = append(transportServers, transportServer) } transportServers, err = annotations.Filter(transportServers, ts.annotationFilter) if err != nil { return nil, fmt.Errorf("failed to filter TransportServers: %w", err) } endpoints := ts.endpointsFromTransportServers(transportServers) return MergeEndpoints(endpoints), nil } func (ts *f5TransportServerSource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for TransportServer") _, _ = ts.transportServerInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } // endpointsFromTransportServers extracts the endpoints from a slice of TransportServers func (ts *f5TransportServerSource) endpointsFromTransportServers(transportServers []*f5.TransportServer) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint for _, transportServer := range transportServers { if !hasValidTransportServerIP(transportServer) { log.Warnf("F5 TransportServer %s/%s is missing a valid IP address, skipping endpoint creation.", transportServer.Namespace, transportServer.Name) continue } resource := fmt.Sprintf("f5-transportserver/%s/%s", transportServer.Namespace, transportServer.Name) ttl := annotations.TTLFromAnnotations(transportServer.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(transportServer.Annotations) if len(targets) == 0 && transportServer.Spec.VirtualServerAddress != "" { targets = append(targets, transportServer.Spec.VirtualServerAddress) } if len(targets) == 0 && transportServer.Status.VSAddress != "" { targets = append(targets, transportServer.Status.VSAddress) } endpoints = append(endpoints, endpoint.EndpointsForHostname(transportServer.Spec.Host, targets, ttl, nil, "", resource)...) } return endpoints } // newUnstructuredConverter returns a new unstructuredConverter initialized func newTSUnstructuredConverter() (*unstructuredConverter, error) { uc := &unstructuredConverter{ scheme: runtime.NewScheme(), } // Add the core types we need uc.scheme.AddKnownTypes(f5TransportServerGVR.GroupVersion(), &f5.TransportServer{}, &f5.TransportServerList{}) if err := scheme.AddToScheme(uc.scheme); err != nil { return nil, err } return uc, nil } func hasValidTransportServerIP(vs *f5.TransportServer) bool { normalizedAddress := strings.ToLower(vs.Status.VSAddress) return normalizedAddress != "none" && normalizedAddress != "" } ================================================ FILE: source/f5_transportserver_test.go ================================================ /* Copyright 2022 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 source import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" fakeDynamic "k8s.io/client-go/dynamic/fake" fakeKube "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" f5 "github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1" ) const defaultF5TransportServerNamespace = "transportserver" func TestF5TransportServerEndpoints(t *testing.T) { t.Parallel() tests := []struct { name string annotationFilter string transportServer f5.TransportServer expected []*endpoint.Endpoint }{ { name: "F5 TransportServer with target annotation", annotationFilter: "", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5TransportServerNamespace, Annotations: map[string]string{ annotations.TargetKey: "192.168.1.150", }, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.200", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.150"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-transportserver/transportserver/test-vs", }, }, }, }, { name: "F5 TransportServer with host and VirtualServerAddress set", annotationFilter: "", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5TransportServerNamespace, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.200", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-transportserver/transportserver/test-vs", }, }, }, }, { name: "F5 TransportServer with host set and IP address from the status field", annotationFilter: "", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5TransportServerNamespace, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-transportserver/transportserver/test-vs", }, }, }, }, { name: "F5 TransportServer with no IP address set", annotationFilter: "", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5TransportServerNamespace, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", }, Status: f5.CustomResourceStatus{ VSAddress: "", }, }, expected: nil, }, { name: "F5 TransportServer with matching annotation filter", annotationFilter: "foo=bar", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5TransportServerNamespace, Annotations: map[string]string{ "foo": "bar", }, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-transportserver/transportserver/test-vs", }, }, }, }, { name: "F5 TransportServer with non-matching annotation filter", annotationFilter: "foo=bar", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5TransportServerNamespace, Annotations: map[string]string{ "bar": "foo", }, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: nil, }, { name: "F5 TransportServer TTL annotation", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5TransportServerNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "600", }, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 600, Labels: endpoint.Labels{ "resource": "f5-transportserver/transportserver/test-vs", }, }, }, }, { name: "F5 TransportServer with error status but valid IP", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-ts", Namespace: defaultF5TransportServerNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "600", }, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "ERROR", Error: "Some error status message", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 600, Labels: endpoint.Labels{ "resource": "f5-transportserver/transportserver/test-ts", }, }, }, }, { name: "F5 TransportServer with missing IP address and OK status", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-ts", Namespace: defaultF5TransportServerNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "600", }, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", IPAMLabel: "test", }, Status: f5.CustomResourceStatus{ VSAddress: "None", Status: "OK", }, }, expected: nil, }, { name: "F5 TransportServer does not support provider-specific annotations", transportServer: f5.TransportServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5TransportServerGVR.GroupVersion().String(), Kind: "TransportServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5TransportServerNamespace, Annotations: map[string]string{ annotations.AWSPrefix + "weight": "10", }, }, Spec: f5.TransportServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-transportserver/transportserver/test-vs", }, }, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(f5TransportServerGVR.GroupVersion(), &f5.TransportServer{}, &f5.TransportServerList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) transportServer := unstructured.Unstructured{} transportServerJSON, err := json.Marshal(tc.transportServer) require.NoError(t, err) assert.NoError(t, transportServer.UnmarshalJSON(transportServerJSON)) // Create TransportServer resources _, err = fakeDynamicClient.Resource(f5TransportServerGVR).Namespace(defaultF5TransportServerNamespace).Create(t.Context(), &transportServer, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewF5TransportServerSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultF5TransportServerNamespace, AnnotationFilter: tc.annotationFilter, }) require.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(f5TransportServerGVR).Namespace(defaultF5TransportServerNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) assert.Len(t, endpoints, len(tc.expected)) assert.Equal(t, tc.expected, endpoints) }) } } ================================================ FILE: source/f5_virtualserver.go ================================================ /* Copyright 2022 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 source import ( "context" "errors" "fmt" "strings" f5 "github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1" log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/informers" ) var f5VirtualServerGVR = schema.GroupVersionResource{ Group: "cis.f5.com", Version: "v1", Resource: "virtualservers", } // virtualServerSource is an implementation of Source for F5 VirtualServer objects. // // +externaldns:source:name=f5-virtualserver // +externaldns:source:category=Load Balancers // +externaldns:source:description=Creates DNS entries from F5 VirtualServer resources // +externaldns:source:resources=VirtualServer.cis.f5.com // +externaldns:source:filters=annotation // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=false // +externaldns:source:provider-specific=false type f5VirtualServerSource struct { dynamicKubeClient dynamic.Interface virtualServerInformer kubeinformers.GenericInformer kubeClient kubernetes.Interface annotationFilter string namespace string unstructuredConverter *unstructuredConverter } func NewF5VirtualServerSource( ctx context.Context, dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, cfg *Config, ) (Source, error) { informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil) virtualServerInformer := informerFactory.ForResource(f5VirtualServerGVR) _, _ = virtualServerInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil { return nil, err } uc, err := newVSUnstructuredConverter() if err != nil { return nil, fmt.Errorf("failed to setup unstructured converter: %w", err) } return &f5VirtualServerSource{ dynamicKubeClient: dynamicKubeClient, virtualServerInformer: virtualServerInformer, kubeClient: kubeClient, namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, unstructuredConverter: uc, }, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all VirtualServers in the source's namespace(s). func (vs *f5VirtualServerSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { virtualServerObjects, err := vs.virtualServerInformer.Lister().ByNamespace(vs.namespace).List(labels.Everything()) if err != nil { return nil, err } var virtualServers []*f5.VirtualServer for _, vsObj := range virtualServerObjects { unstructuredHost, ok := vsObj.(*unstructured.Unstructured) if !ok { return nil, errors.New("could not convert") } virtualServer := &f5.VirtualServer{} err := vs.unstructuredConverter.scheme.Convert(unstructuredHost, virtualServer, nil) if err != nil { return nil, err } virtualServers = append(virtualServers, virtualServer) } virtualServers, err = annotations.Filter(virtualServers, vs.annotationFilter) if err != nil { return nil, fmt.Errorf("failed to filter VirtualServers: %w", err) } endpoints := vs.endpointsFromVirtualServers(virtualServers) return MergeEndpoints(endpoints), nil } func (vs *f5VirtualServerSource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for VirtualServer") _, _ = vs.virtualServerInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } // endpointsFromVirtualServers extracts the endpoints from a slice of VirtualServers func (vs *f5VirtualServerSource) endpointsFromVirtualServers(virtualServers []*f5.VirtualServer) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint for _, virtualServer := range virtualServers { if !hasValidVirtualServerIP(virtualServer) { log.Warnf("F5 VirtualServer %s/%s is missing a valid IP address, skipping endpoint creation.", virtualServer.Namespace, virtualServer.Name) continue } resource := fmt.Sprintf("f5-virtualserver/%s/%s", virtualServer.Namespace, virtualServer.Name) ttl := annotations.TTLFromAnnotations(virtualServer.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(virtualServer.Annotations) if len(targets) == 0 && virtualServer.Spec.VirtualServerAddress != "" { targets = append(targets, virtualServer.Spec.VirtualServerAddress) } if len(targets) == 0 && virtualServer.Status.VSAddress != "" { targets = append(targets, virtualServer.Status.VSAddress) } endpoints = append(endpoints, endpoint.EndpointsForHostname(virtualServer.Spec.Host, targets, ttl, nil, "", resource)...) for _, alias := range virtualServer.Spec.HostAliases { if alias != "" { endpoints = append(endpoints, endpoint.EndpointsForHostname(alias, targets, ttl, nil, "", resource)...) } } } return endpoints } // newUnstructuredConverter returns a new unstructuredConverter initialized func newVSUnstructuredConverter() (*unstructuredConverter, error) { uc := &unstructuredConverter{ scheme: runtime.NewScheme(), } // Add the core types we need uc.scheme.AddKnownTypes(f5VirtualServerGVR.GroupVersion(), &f5.VirtualServer{}, &f5.VirtualServerList{}) if err := scheme.AddToScheme(uc.scheme); err != nil { return nil, err } return uc, nil } func hasValidVirtualServerIP(vs *f5.VirtualServer) bool { normalizedAddress := strings.ToLower(vs.Status.VSAddress) return normalizedAddress != "none" && normalizedAddress != "" } ================================================ FILE: source/f5_virtualserver_test.go ================================================ /* Copyright 2022 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 source import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" fakeDynamic "k8s.io/client-go/dynamic/fake" fakeKube "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" f5 "github.com/F5Networks/k8s-bigip-ctlr/v2/config/apis/cis/v1" ) const defaultF5VirtualServerNamespace = "virtualserver" func TestF5VirtualServerEndpoints(t *testing.T) { t.Parallel() tests := []struct { name string annotationFilter string virtualServer f5.VirtualServer expected []*endpoint.Endpoint }{ { name: "F5 VirtualServer with target annotation", annotationFilter: "", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ annotations.TargetKey: "192.168.1.150", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.200", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.150"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with host and virtualServerAddress set", annotationFilter: "", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.200", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with host set and IP address from the status field", annotationFilter: "", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with no IP address set", annotationFilter: "", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", }, Status: f5.CustomResourceStatus{ VSAddress: "", }, }, expected: nil, }, { name: "F5 VirtualServer with matching annotation filter", annotationFilter: "foo=bar", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ "foo": "bar", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with non-matching annotation filter", annotationFilter: "foo=bar", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ "bar": "foo", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: nil, }, { name: "F5 VirtualServer TTL annotation", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "600", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 600, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with error status but valid IP", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "600", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "ERROR", Error: "Some error status message", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 600, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with missing IP address and OK status", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "600", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", IPAMLabel: "test", }, Status: f5.CustomResourceStatus{ VSAddress: "None", Status: "OK", }, }, expected: nil, }, { name: "F5 VirtualServer with hostAliases", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", HostAliases: []string{"alias1.example.com", "alias2.example.com"}, }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "alias1.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, { DNSName: "alias2.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with hostAliases and target annotation", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ annotations.TargetKey: "192.168.1.150", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", HostAliases: []string{"alias1.example.com", "alias2.example.com"}, }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.150"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, { DNSName: "alias1.example.com", Targets: []string{"192.168.1.150"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, { DNSName: "alias2.example.com", Targets: []string{"192.168.1.150"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with hostAliases and TTL annotation", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "300", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", HostAliases: []string{"alias1.example.com", "alias2.example.com"}, }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 300, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, { DNSName: "alias1.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 300, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, { DNSName: "alias2.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 300, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with empty hostAliases", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", HostAliases: []string{}, }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer with hostAliases containing empty strings", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", HostAliases: []string{"alias1.example.com", "", "alias2.example.com"}, }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, { DNSName: "alias1.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, { DNSName: "alias2.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, { name: "F5 VirtualServer does not support provider-specific annotations", virtualServer: f5.VirtualServer{ TypeMeta: metav1.TypeMeta{ APIVersion: f5VirtualServerGVR.GroupVersion().String(), Kind: "VirtualServer", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-vs", Namespace: defaultF5VirtualServerNamespace, Annotations: map[string]string{ annotations.AWSPrefix + "weight": "10", }, }, Spec: f5.VirtualServerSpec{ Host: "www.example.com", VirtualServerAddress: "192.168.1.100", }, Status: f5.CustomResourceStatus{ VSAddress: "192.168.1.100", Status: "OK", }, }, expected: []*endpoint.Endpoint{ { DNSName: "www.example.com", Targets: []string{"192.168.1.100"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "f5-virtualserver/virtualserver/test-vs", }, }, }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { fakeKubernetesClient := fakeKube.NewClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(f5VirtualServerGVR.GroupVersion(), &f5.VirtualServer{}, &f5.VirtualServerList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) virtualServer := unstructured.Unstructured{} virtualServerJSON, err := json.Marshal(tc.virtualServer) require.NoError(t, err) assert.NoError(t, virtualServer.UnmarshalJSON(virtualServerJSON)) // Create VirtualServer resources _, err = fakeDynamicClient.Resource(f5VirtualServerGVR).Namespace(defaultF5VirtualServerNamespace).Create(t.Context(), &virtualServer, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewF5VirtualServerSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultF5VirtualServerNamespace, AnnotationFilter: tc.annotationFilter, }) require.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(f5VirtualServerGVR).Namespace(defaultF5VirtualServerNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) assert.Len(t, endpoints, len(tc.expected)) validateEndpoints(t, endpoints, tc.expected) }) } } ================================================ FILE: source/fake.go ================================================ /* Copyright 2017 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. */ /* Note: currently only supports IP targets (A records), not hostname targets */ package source import ( "context" "fmt" "math/rand" "net" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/source/types" ) // fakeSource is an implementation of Source that provides dummy endpoints for // testing/dry-running of dns providers without needing an attached Kubernetes cluster. // // +externaldns:source:name=fake // +externaldns:source:category=Testing // +externaldns:source:description=Provides dummy endpoints for testing and dry-running // +externaldns:source:resources=Fake Endpoints // +externaldns:source:filters= // +externaldns:source:namespace= // +externaldns:source:fqdn-template=true // +externaldns:source:events=true // +externaldns:source:provider-specific=false type fakeSource struct { dnsName string } const ( defaultFQDNTemplate = "example.com" ) // NewFakeSource creates a new fakeSource with the given config. func NewFakeSource(fqdnTemplate string) (Source, error) { if fqdnTemplate == "" { fqdnTemplate = defaultFQDNTemplate } return &fakeSource{ dnsName: fqdnTemplate, }, nil } func (sc *fakeSource) AddEventHandler(_ context.Context, _ func()) { } // Endpoints returns endpoint objects. func (sc *fakeSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { endpoints := make([]*endpoint.Endpoint, 10) for i := range 10 { endpoints[i] = sc.generateEndpoint() } return MergeEndpoints(endpoints), nil } func (sc *fakeSource) generateEndpoint() *endpoint.Endpoint { ep := endpoint.NewEndpoint( generateDNSName(4, sc.dnsName), endpoint.RecordTypeA, generateIPAddress(), ) ep.SetIdentifier = types.Fake ep.WithRefObject(events.NewObjectReference(&v1.Pod{ TypeMeta: metav1.TypeMeta{ Kind: "Pod", APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: types.Fake + "-" + ep.DNSName, Namespace: v1.NamespaceDefault, }, }, types.Fake)) return ep } func generateIPAddress() string { // 192.0.2.[1-255] is reserved by RFC 5737 for documentation and examples return net.IPv4( byte(192), byte(0), byte(2), byte(rand.Intn(253)+1), ).String() } var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz") func generateDNSName(prefixLength int, dnsName string) string { prefixBytes := make([]rune, prefixLength) for i := range prefixBytes { prefixBytes[i] = letterRunes[rand.Intn(len(letterRunes))] } prefixStr := string(prefixBytes) return fmt.Sprintf("%s.%s", prefixStr, dnsName) } ================================================ FILE: source/fake_test.go ================================================ /* Copyright 2017 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 source import ( "context" "net" "regexp" "testing" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" ) // Validate that FakeSource is a source var _ Source = &fakeSource{} func generateTestEndpoints() []*endpoint.Endpoint { sc, _ := NewFakeSource("") endpoints, _ := sc.Endpoints(context.Background()) return endpoints } func TestFakeSourceReturnsTenEndpoints(t *testing.T) { endpoints := generateTestEndpoints() count := len(endpoints) if count != 10 { t.Error(count) } } func TestFakeEndpointsBelongToDomain(t *testing.T) { validRecord := regexp.MustCompile(`^[a-z]{4}\.example\.com$`) endpoints := generateTestEndpoints() for _, e := range endpoints { valid := validRecord.MatchString(e.DNSName) if !valid { t.Error(e.DNSName) } } } func TestFakeEndpointsResolveToIPAddresses(t *testing.T) { endpoints := generateTestEndpoints() for _, e := range endpoints { ip := net.ParseIP(e.Targets[0]) if ip == nil { t.Error(e) } } } func TestFakeSource_GenerateEndpoint_RefObject(t *testing.T) { sc, _ := NewFakeSource("example.com") fs := sc.(*fakeSource) ep := fs.generateEndpoint() require.NotNil(t, ep, "endpoint should not be nil") require.NotNil(t, ep.RefObject()) require.Equal(t, "Pod", ep.RefObject().Kind) } ================================================ FILE: source/fqdn/fqdn.go ================================================ /* 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 fqdn import ( "bytes" "encoding/json" "fmt" "maps" "reflect" "slices" "strings" "text/template" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/external-dns/endpoint" ) func ParseTemplate(input string) (*template.Template, error) { if input == "" { return nil, nil } funcs := template.FuncMap{ "contains": strings.Contains, "trimPrefix": strings.TrimPrefix, "trimSuffix": strings.TrimSuffix, "trim": strings.TrimSpace, "toLower": strings.ToLower, "replace": replace, "isIPv6": isIPv6String, "isIPv4": isIPv4String, "hasKey": hasKey, "fromJson": fromJson, } return template.New("endpoint").Funcs(funcs).Parse(input) } type kubeObject interface { runtime.Object metav1.Object } // ExecTemplate executes a template against a Kubernetes object and returns hostnames. // It infers Kind if TypeMeta is missing. Returns error if obj is nil or execution fails. func ExecTemplate(tmpl *template.Template, obj kubeObject) ([]string, error) { if obj == nil { return nil, fmt.Errorf("object is nil") } // Kubernetes API doesn't populate TypeMeta (Kind/APIVersion) when retrieving // objects via informers. because the client already knows what type it requested. This reduces payload size. // Set it so templates can use .Kind and .APIVersion // TODO: all sources to transform Informer().SetTransform() gvk := obj.GetObjectKind().GroupVersionKind() if gvk.Kind == "" { gvks, _, err := scheme.Scheme.ObjectKinds(obj) if err == nil && len(gvks) > 0 { gvk = gvks[0] } else { // Fallback to reflection for types not in scheme gvk = schema.GroupVersionKind{Kind: reflect.TypeOf(obj).Elem().Name()} } obj.GetObjectKind().SetGroupVersionKind(gvk) } var buf bytes.Buffer if err := tmpl.Execute(&buf, obj); err != nil { kind := obj.GetObjectKind().GroupVersionKind().Kind return nil, fmt.Errorf("failed to apply template on %s %s/%s: %w", kind, obj.GetNamespace(), obj.GetName(), err) } hosts := strings.Split(buf.String(), ",") hostnames := make(map[string]struct{}, len(hosts)) for _, name := range hosts { name = strings.TrimSpace(name) name = strings.TrimSuffix(name, ".") if name != "" { hostnames[name] = struct{}{} } } return slices.Sorted(maps.Keys(hostnames)), nil } // replace all instances of oldValue with newValue in target string. // adheres to syntax from https://masterminds.github.io/sprig/strings.html. func replace(oldValue, newValue, target string) string { return strings.ReplaceAll(target, oldValue, newValue) } // isIPv6String reports whether the target string is an IPv6 address, // including IPv4-mapped IPv6 addresses. func isIPv6String(target string) bool { return endpoint.SuitableType(target) == endpoint.RecordTypeAAAA } // isIPv4String reports whether the target string is an IPv4 address. func isIPv4String(target string) bool { return endpoint.SuitableType(target) == endpoint.RecordTypeA } // hasKey checks if a key exists in a map. This is needed because Go templates' // `index` function returns the zero value ("") for missing keys, which is // indistinguishable from keys with empty values. Kubernetes uses empty-value // labels for markers (e.g., `service.kubernetes.io/headless: ""`), so we need // explicit key existence checking. func hasKey(m map[string]string, key string) bool { _, ok := m[key] return ok } // fromJson decodes a JSON string into a Go value (map, slice, etc.). // This enables templates to work with structured data stored as JSON strings // in complex labels or annotations or Configmap data fields, e.g. ranging over a list of entries: // // {{ range $entry := (index .Data "entries" | fromJson) }}{{ index $entry "dns" }},{{ end }} // // Returns nil if the input is not valid JSON. func fromJson(v string) any { var output any _ = json.Unmarshal([]byte(v), &output) return output } // CombineWithTemplatedEndpoints merges annotation-based endpoints with template-based endpoints // according to the FQDN template configuration. // // Logic: // - If fqdnTemplate is nil, returns original endpoints unchanged // - If combineFQDNAnnotation is true, appends templated endpoints to existing // - If combineFQDNAnnotation is false and endpoints is empty, uses templated endpoints // - If combineFQDNAnnotation is false and endpoints exist, returns original unchanged func CombineWithTemplatedEndpoints( endpoints []*endpoint.Endpoint, fqdnTemplate *template.Template, combineFQDNAnnotation bool, templateFunc func() ([]*endpoint.Endpoint, error), ) ([]*endpoint.Endpoint, error) { if fqdnTemplate == nil { return endpoints, nil } if !combineFQDNAnnotation && len(endpoints) > 0 { return endpoints, nil } templatedEndpoints, err := templateFunc() if err != nil { return nil, fmt.Errorf("failed to get endpoints from template: %w", err) } if combineFQDNAnnotation { return append(endpoints, templatedEndpoints...), nil } return templatedEndpoints, nil } ================================================ FILE: source/fqdn/fqdn_test.go ================================================ /* 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 fqdn import ( "errors" "testing" "text/template" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/external-dns/endpoint" ) func TestParseTemplate(t *testing.T) { for _, tt := range []struct { name string annotationFilter string fqdnTemplate string combineFQDNAndAnnotation bool expectError bool }{ { name: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { name: "valid empty template", expectError: false, }, { name: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { name: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", }, { name: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", combineFQDNAndAnnotation: true, }, { name: "non-empty annotation filter label", expectError: false, annotationFilter: "kubernetes.io/ingress.class=nginx", }, { name: "replace template function", expectError: false, fqdnTemplate: "{{\"hello.world\" | replace \".\" \"-\"}}.ext-dns.test.com", }, { name: "isIPv4 template function with valid IPv4", expectError: false, fqdnTemplate: "{{if isIPv4 \"192.168.1.1\"}}valid{{else}}invalid{{end}}.ext-dns.test.com", }, { name: "isIPv4 template function with invalid IPv4", expectError: false, fqdnTemplate: "{{if isIPv4 \"not.an.ip.addr\"}}valid{{else}}invalid{{end}}.ext-dns.test.com", }, { name: "isIPv6 template function with valid IPv6", expectError: false, fqdnTemplate: "{{if isIPv6 \"2001:db8::1\"}}valid{{else}}invalid{{end}}.ext-dns.test.com", }, { name: "isIPv6 template function with invalid IPv6", expectError: false, fqdnTemplate: "{{if isIPv6 \"not:ipv6:addr\"}}valid{{else}}invalid{{end}}.ext-dns.test.com", }, } { t.Run(tt.name, func(t *testing.T) { _, err := ParseTemplate(tt.fqdnTemplate) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func TestExecTemplate(t *testing.T) { tests := []struct { name string tmpl string obj kubeObject want []string wantErr bool }{ { name: "simple template", tmpl: "{{ .Name }}.example.com, {{ .Namespace }}.example.org", obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: "default", }, }, want: []string{"default.example.org", "test.example.com"}, }, { name: "multiple hostnames", tmpl: "{{.Name}}.example.com, {{.Name}}.example.org", obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: "default", }, }, want: []string{"test.example.com", "test.example.org"}, }, { name: "trim spaces", tmpl: " {{ trim .Name}}.example.com. ", obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: " test ", }, }, want: []string{"test.example.com"}, }, { name: "trim prefix", tmpl: `{{ trimPrefix .Name "the-" }}.example.com`, obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "the-test", Namespace: "default", }, }, want: []string{"test.example.com"}, }, { name: "trim suffix", tmpl: `{{ trimSuffix .Name "-v2" }}.example.com`, obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test-v2", Namespace: "default", }, }, want: []string{"test.example.com"}, }, { name: "replace dash", tmpl: `{{ replace "-" "." .Name }}.example.com`, obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test-v2", Namespace: "default", }, }, want: []string{"test.v2.example.com"}, }, { name: "annotations and labels", tmpl: "{{.Labels.environment }}.example.com, {{ index .ObjectMeta.Annotations \"alb.ingress.kubernetes.io/scheme\" }}.{{ .Labels.environment }}.{{ index .ObjectMeta.Annotations \"dns.company.com/zone\" }}", obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: "default", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "test.example.com, test.example.org", "kubernetes.io/role/internal-elb": "true", "alb.ingress.kubernetes.io/scheme": "internal", "dns.company.com/zone": "company.org", }, Labels: map[string]string{ "environment": "production", "app": "myapp", "tier": "backend", "role": "worker", "version": "1", }, }, }, want: []string{"internal.production.company.org", "production.example.com"}, }, { name: "labels to lowercase", tmpl: "{{ toLower .Labels.department }}.example.org", obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: "default", Labels: map[string]string{ "department": "FINANCE", "app": "myapp", }, }, }, want: []string{"finance.example.org"}, }, { name: "generate multiple hostnames with if condition", tmpl: "{{ if contains (index .ObjectMeta.Annotations \"external-dns.alpha.kubernetes.io/hostname\") \"example.com\" }}{{ toLower .Labels.hostoverride }}{{end}}", obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: "default", Labels: map[string]string{ "hostoverride": "abrakadabra.google.com", "app": "myapp", }, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "test.example.com", }, }, }, want: []string{"abrakadabra.google.com"}, }, { name: "ignore empty template output", tmpl: "{{ if eq .Name \"other\" }}{{ .Name }}.example.com{{ end }}", obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, }, want: nil, }, { name: "ignore trailing comma output", tmpl: "{{ .Name }}.example.com,", obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", }, }, want: []string{"test.example.com"}, }, { name: "contains label with empty value", tmpl: `{{if hasKey .Labels "service.kubernetes.io/headless"}}{{ .Name }}.example.com,{{end}}`, obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Labels: map[string]string{ "service.kubernetes.io/headless": "", }, }, }, want: []string{"test.example.com"}, }, { name: "result only contains unique values", tmpl: `{{ .Name }}.example.com,{{ .Name }}.example.com,{{ .Name }}.example.com`, obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Labels: map[string]string{ "service.kubernetes.io/headless": "", }, }, }, want: []string{"test.example.com"}, }, { name: "dns entries in labels", tmpl: ` {{ if hasKey .Labels "records" }}{{ range $entry := (index .Labels "records" | fromJson) }}{{ index $entry "dns" }},{{ end }}{{ end }}`, obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Labels: map[string]string{ "records": ` [{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]`, }, }, }, want: []string{"entry1.internal.tld", "entry2.example.tld"}, }, { name: "configmap with multiple entries", tmpl: `{{ range $entry := (index .Data "entries" | fromJson) }}{{ index $entry "dns" }},{{ end }}`, obj: &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: "test-configmap", }, Data: map[string]string{ "entries": ` [{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]`, }, }, want: []string{"entry1.internal.tld", "entry2.example.tld"}, }, { name: "rancher publicEndpoints annotation", tmpl: ` {{ range $entry := (index .Annotations "field.cattle.io/publicEndpoints" | fromJson) }}{{ index $entry "hostname" }},{{ end }}`, obj: &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Annotations: map[string]string{ "field.cattle.io/publicEndpoints": ` [{"addresses":[""],"port":80,"protocol":"HTTP", "serviceName":"development:keycloak-ha-service", "ingressName":"development:keycloak-ha-ingress", "hostname":"keycloak.snip.com","allNodes":false }]`, }, }, }, want: []string{"keycloak.snip.com"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpl, err := ParseTemplate(tt.tmpl) require.NoError(t, err) got, err := ExecTemplate(tmpl, tt.obj) require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } func TestExecTemplateEmptyObject(t *testing.T) { tmpl, err := ParseTemplate("{{ toLower .Labels.department }}.example.org") require.NoError(t, err) _, err = ExecTemplate(tmpl, nil) assert.Error(t, err) } func TestExecTemplatePopulatesEmptyKind(t *testing.T) { // Test that Kind is populated when initially empty (simulates informer behavior) tmpl, err := ParseTemplate("{{ .Kind }}.{{ .Name }}.example.com") require.NoError(t, err) // Create object with empty TypeMeta (Kind == "") obj := &testObject{ ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: "default", }, } // Kind should be empty initially assert.Empty(t, obj.GetObjectKind().GroupVersionKind().Kind) got, err := ExecTemplate(tmpl, obj) require.NoError(t, err) // Kind should now be populated via reflection assert.Equal(t, "testObject", obj.GetObjectKind().GroupVersionKind().Kind) assert.Equal(t, []string{"testObject.test.example.com"}, got) } func TestExecTemplatePreservesExistingKind(t *testing.T) { // Test that existing Kind is not overwritten tmpl, err := ParseTemplate("{{ .Kind }}.{{ .Name }}.example.com") require.NoError(t, err) obj := &testObject{ TypeMeta: metav1.TypeMeta{ Kind: "CustomKind", APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: "test", Namespace: "default", }, } got, err := ExecTemplate(tmpl, obj) require.NoError(t, err) // Kind should remain unchanged assert.Equal(t, "CustomKind", obj.GetObjectKind().GroupVersionKind().Kind) assert.Equal(t, []string{"CustomKind.test.example.com"}, got) } func TestFqdnTemplate(t *testing.T) { tests := []struct { name string fqdnTemplate string expectedError bool }{ { name: "empty template", fqdnTemplate: "", expectedError: false, }, { name: "valid template", fqdnTemplate: "{{ .Name }}.example.com", expectedError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpl, err := ParseTemplate(tt.fqdnTemplate) if tt.expectedError { require.Error(t, err) assert.Nil(t, tmpl) } else { require.NoError(t, err) if tt.fqdnTemplate == "" { assert.Nil(t, tmpl) } else { assert.NotNil(t, tmpl) } } }) } } func TestReplace(t *testing.T) { for _, tt := range []struct { name string oldValue string newValue string target string expected string }{ { name: "simple replacement", oldValue: "old", newValue: "new", target: "old-value", expected: "new-value", }, { name: "multiple replacements", oldValue: ".", newValue: "-", target: "hello.world.com", expected: "hello-world-com", }, { name: "no replacement needed", oldValue: "x", newValue: "y", target: "hello-world", expected: "hello-world", }, { name: "empty strings", oldValue: "", newValue: "", target: "test", expected: "test", }, } { t.Run(tt.name, func(t *testing.T) { result := replace(tt.oldValue, tt.newValue, tt.target) assert.Equal(t, tt.expected, result) }) } } func TestIsIPv6String(t *testing.T) { for _, tt := range []struct { name string input string expected bool }{ { name: "valid IPv6", input: "2001:db8::1", expected: true, }, { name: "valid IPv6 with multiple segments", input: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", expected: true, }, { name: "valid IPv4-mapped IPv6", input: "::ffff:192.168.1.1", expected: true, }, { name: "invalid IPv6", input: "not:ipv6:addr", expected: false, }, { name: "IPv4 address", input: "192.168.1.1", expected: false, }, { name: "empty string", input: "", expected: false, }, } { t.Run(tt.name, func(t *testing.T) { result := isIPv6String(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestIsIPv4String(t *testing.T) { for _, tt := range []struct { name string input string expected bool }{ { name: "valid IPv4", input: "192.168.1.1", expected: true, }, { name: "invalid IPv4", input: "256.256.256.256", expected: false, }, { name: "IPv6 address", input: "2001:db8::1", expected: false, }, { name: "invalid format", input: "not.an.ip", expected: false, }, { name: "empty string", input: "", expected: false, }, } { t.Run(tt.name, func(t *testing.T) { result := isIPv4String(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestHasKey(t *testing.T) { for _, tt := range []struct { name string m map[string]string key string expected bool }{ { name: "key exists with non-empty value", m: map[string]string{"foo": "bar"}, key: "foo", expected: true, }, { name: "key exists with empty value", m: map[string]string{"service.kubernetes.io/headless": ""}, key: "service.kubernetes.io/headless", expected: true, }, { name: "key does not exist", m: map[string]string{"foo": "bar"}, key: "baz", expected: false, }, { name: "nil map", m: nil, key: "foo", expected: false, }, { name: "empty map", m: map[string]string{}, key: "foo", expected: false, }, } { t.Run(tt.name, func(t *testing.T) { result := hasKey(tt.m, tt.key) assert.Equal(t, tt.expected, result) }) } } func TestFromJson(t *testing.T) { for _, tt := range []struct { name string input string expected any }{ { name: "map of strings", input: `{"dns":"entry1.internal.tld","target":"10.10.10.10"}`, expected: map[string]any{"dns": "entry1.internal.tld", "target": "10.10.10.10"}, }, { name: "slice of maps", input: `[{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]`, expected: []any{ map[string]any{"dns": "entry1.internal.tld", "target": "10.10.10.10"}, map[string]any{"dns": "entry2.example.tld", "target": "my.cluster.local"}, }, }, { name: "null input", input: "null", expected: nil, }, { name: "empty object", input: "{}", expected: map[string]any{}, }, { name: "string value", input: `"hello"`, expected: "hello", }, { name: "invalid json", input: "not valid json", expected: nil, }, } { t.Run(tt.name, func(t *testing.T) { result := fromJson(tt.input) assert.Equal(t, tt.expected, result) }) } } type testObject struct { metav1.TypeMeta metav1.ObjectMeta } func (t *testObject) DeepCopyObject() runtime.Object { return &testObject{ TypeMeta: t.TypeMeta, ObjectMeta: *t.ObjectMeta.DeepCopy(), } } func TestExecTemplateExecutionError(t *testing.T) { tmpl, err := ParseTemplate("{{ call .Name }}") require.NoError(t, err) obj := &metav1.PartialObjectMetadata{ TypeMeta: metav1.TypeMeta{ Kind: "TestKind", }, ObjectMeta: metav1.ObjectMeta{ Name: "test-name", Namespace: "default", }, } _, err = ExecTemplate(tmpl, obj) require.Error(t, err) assert.Contains(t, err.Error(), "failed to apply template on TestKind default/test-name") } func TestCombineWithTemplatedEndpoints(t *testing.T) { // Create a dummy template for tests that need one dummyTemplate := template.Must(template.New("test").Parse("{{.Name}}")) annotationEndpoints := []*endpoint.Endpoint{ endpoint.NewEndpoint("annotation.example.com", endpoint.RecordTypeA, "1.2.3.4"), } templatedEndpoints := []*endpoint.Endpoint{ endpoint.NewEndpoint("template.example.com", endpoint.RecordTypeA, "5.6.7.8"), } successTemplateFunc := func() ([]*endpoint.Endpoint, error) { return templatedEndpoints, nil } errorTemplateFunc := func() ([]*endpoint.Endpoint, error) { return nil, errors.New("template error") } tests := []struct { name string endpoints []*endpoint.Endpoint fqdnTemplate *template.Template combineFQDNAnnotation bool templateFunc func() ([]*endpoint.Endpoint, error) want []*endpoint.Endpoint wantErr bool }{ { name: "nil template returns original endpoints", endpoints: annotationEndpoints, fqdnTemplate: nil, templateFunc: successTemplateFunc, want: annotationEndpoints, }, { name: "combine=false with existing endpoints returns original", endpoints: annotationEndpoints, fqdnTemplate: dummyTemplate, templateFunc: successTemplateFunc, want: annotationEndpoints, }, { name: "combine=false with empty endpoints returns templated", endpoints: []*endpoint.Endpoint{}, fqdnTemplate: dummyTemplate, templateFunc: successTemplateFunc, want: templatedEndpoints, }, { name: "combine=true appends templated to existing", endpoints: annotationEndpoints, fqdnTemplate: dummyTemplate, combineFQDNAnnotation: true, templateFunc: successTemplateFunc, want: append(annotationEndpoints, templatedEndpoints...), }, { name: "combine=true with empty endpoints returns templated", endpoints: []*endpoint.Endpoint{}, fqdnTemplate: dummyTemplate, combineFQDNAnnotation: true, templateFunc: successTemplateFunc, want: templatedEndpoints, }, { name: "template error is propagated", endpoints: []*endpoint.Endpoint{}, fqdnTemplate: dummyTemplate, templateFunc: errorTemplateFunc, want: nil, wantErr: true, }, { name: "nil endpoints with combine=false returns templated", endpoints: nil, fqdnTemplate: dummyTemplate, templateFunc: successTemplateFunc, want: templatedEndpoints, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := CombineWithTemplatedEndpoints( tt.endpoints, tt.fqdnTemplate, tt.combineFQDNAnnotation, tt.templateFunc, ) if tt.wantErr { require.Error(t, err) require.ErrorContains(t, err, "failed to get endpoints from template") return } require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } ================================================ FILE: source/gateway.go ================================================ /* Copyright 2021 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 source import ( "context" "fmt" "sort" "strings" "text/template" log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" "k8s.io/client-go/tools/cache" v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1beta1" gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" gwinformers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions" informers_v1beta1 "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1beta1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" ) const ( gatewayGroup = "gateway.networking.k8s.io" gatewayKind = "Gateway" gatewayHostnameSourceAnnotationOnlyValue = "annotation-only" gatewayHostnameSourceDefinedHostsOnlyValue = "defined-hosts-only" ) type gatewayRoute interface { // Object returns the underlying route object to be used by templates. Object() kubeObject // Metadata returns the route's metadata. Metadata() *metav1.ObjectMeta // Hostnames returns the route's specified hostnames. Hostnames() []v1.Hostname // ParentRefs returns the route's parent references as defined in the route spec. ParentRefs() []v1.ParentReference // Protocol returns the route's protocol type. Protocol() v1.ProtocolType // RouteStatus returns the route's common status. RouteStatus() v1.RouteStatus } type newGatewayRouteInformerFunc func(gwinformers.SharedInformerFactory) gatewayRouteInformer type gatewayRouteInformer interface { List(namespace string, selector labels.Selector) ([]gatewayRoute, error) Informer() cache.SharedIndexInformer } func newGatewayInformerFactory(client gateway.Interface, namespace string, labelSelector labels.Selector) gwinformers.SharedInformerFactory { var opts []gwinformers.SharedInformerOption if namespace != "" { opts = append(opts, gwinformers.WithNamespace(namespace)) } if labelSelector != nil && !labelSelector.Empty() { lbls := labelSelector.String() opts = append(opts, gwinformers.WithTweakListOptions(func(o *metav1.ListOptions) { o.LabelSelector = lbls })) } return gwinformers.NewSharedInformerFactoryWithOptions(client, 0, opts...) } // gatewayRouteSource is an implementation of Source for Gateway API Route objects. // // +externaldns:source:name=gateway-httproute // +externaldns:source:category=Gateway API // +externaldns:source:description=Creates DNS entries from Gateway API HTTPRoute resources // +externaldns:source:resources=HTTPRoute.gateway.networking.k8s.io // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true // // +externaldns:source:name=gateway-grpcroute // +externaldns:source:category=Gateway API // +externaldns:source:description=Creates DNS entries from Gateway API GRPCRoute resources // +externaldns:source:resources=GRPCRoute.gateway.networking.k8s.io // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true // // +externaldns:source:name=gateway-tcproute // +externaldns:source:category=Gateway API // +externaldns:source:description=Creates DNS entries from Gateway API TCPRoute resources // +externaldns:source:resources=TCPRoute.gateway.networking.k8s.io // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true // // +externaldns:source:name=gateway-tlsroute // +externaldns:source:category=Gateway API // +externaldns:source:description=Creates DNS entries from Gateway API TLSRoute resources // +externaldns:source:resources=TLSRoute.gateway.networking.k8s.io // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true // // +externaldns:source:name=gateway-udproute // +externaldns:source:category=Gateway API // +externaldns:source:description=Creates DNS entries from Gateway API UDPRoute resources // +externaldns:source:resources=UDPRoute.gateway.networking.k8s.io // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true type gatewayRouteSource struct { gwName string gwNamespace string gwLabels labels.Selector gwInformer informers_v1beta1.GatewayInformer rtKind string rtNamespace string rtLabels labels.Selector rtAnnotations labels.Selector rtInformer gatewayRouteInformer nsInformer coreinformers.NamespaceInformer fqdnTemplate *template.Template combineFQDNAnnotation bool ignoreHostnameAnnotation bool } func newGatewayRouteSource( ctx context.Context, clients ClientGenerator, config *Config, kind string, newInformerFn newGatewayRouteInformerFunc) (Source, error) { gwLabels, err := getLabelSelector(config.GatewayLabelFilter) if err != nil { return nil, err } rtLabels := config.LabelFilter if rtLabels == nil { rtLabels = labels.Everything() } rtAnnotations, err := getLabelSelector(config.AnnotationFilter) if err != nil { return nil, err } tmpl, err := fqdn.ParseTemplate(config.FQDNTemplate) if err != nil { return nil, err } client, err := clients.GatewayClient() if err != nil { return nil, err } informerFactory := newGatewayInformerFactory(client, config.GatewayNamespace, gwLabels) gwInformer := informerFactory.Gateway().V1beta1().Gateways() // TODO: Gateway informer should be shared across gateway sources. gwInformer.Informer() // Register with factory before starting. rtInformerFactory := informerFactory if config.Namespace != config.GatewayNamespace || !selectorsEqual(rtLabels, gwLabels) { rtInformerFactory = newGatewayInformerFactory(client, config.Namespace, rtLabels) } rtInformer := newInformerFn(rtInformerFactory) rtInformer.Informer() // Register with factory before starting. kubeClient, err := clients.KubeClient() if err != nil { return nil, err } kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, 0) nsInformer := kubeInformerFactory.Core().V1().Namespaces() // TODO: Namespace informer should be shared across gateway sources. nsInformer.Informer() // Register with factory before starting. informerFactory.Start(ctx.Done()) kubeInformerFactory.Start(ctx.Done()) if rtInformerFactory != informerFactory { rtInformerFactory.Start(ctx.Done()) if err := informers.WaitForCacheSync(ctx, rtInformerFactory); err != nil { return nil, err } } if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } if err := informers.WaitForCacheSync(ctx, kubeInformerFactory); err != nil { return nil, err } src := &gatewayRouteSource{ gwName: config.GatewayName, gwNamespace: config.GatewayNamespace, gwLabels: gwLabels, gwInformer: gwInformer, rtKind: kind, rtNamespace: config.Namespace, rtLabels: rtLabels, rtAnnotations: rtAnnotations, rtInformer: rtInformer, nsInformer: nsInformer, fqdnTemplate: tmpl, combineFQDNAnnotation: config.CombineFQDNAndAnnotation, ignoreHostnameAnnotation: config.IgnoreHostnameAnnotation, } return src, nil } func (src *gatewayRouteSource) AddEventHandler(_ context.Context, handler func()) { log.Debugf("Adding event handlers for %s", src.rtKind) eventHandler := eventHandlerFunc(handler) _, _ = src.gwInformer.Informer().AddEventHandler(eventHandler) _, _ = src.rtInformer.Informer().AddEventHandler(eventHandler) _, _ = src.nsInformer.Informer().AddEventHandler(eventHandler) } func (src *gatewayRouteSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint routes, err := src.rtInformer.List(src.rtNamespace, src.rtLabels) if err != nil { return nil, err } gateways, err := src.gwInformer.Lister().Gateways(src.gwNamespace).List(src.gwLabels) if err != nil { return nil, err } namespaces, err := src.nsInformer.Lister().List(labels.Everything()) if err != nil { return nil, err } kind := strings.ToLower(src.rtKind) resolver := newGatewayRouteResolver(src, gateways, namespaces) for _, rt := range routes { // Filter by annotations. meta := rt.Metadata() annots := meta.Annotations if !src.rtAnnotations.Matches(labels.Set(annots)) { continue } if annotations.IsControllerMismatch(meta, src.rtKind) { continue } // Get Route hostnames and their targets. hostTargets, err := resolver.resolve(rt) if err != nil { return nil, err } // TODO: does not follow the pattern of other sources to log empty hostTargets if len(hostTargets) == 0 { log.Debugf("No endpoints could be generated from %s %s/%s", src.rtKind, meta.Namespace, meta.Name) continue } // Create endpoints from hostnames and targets. var routeEndpoints []*endpoint.Endpoint resource := fmt.Sprintf("%s/%s/%s", kind, meta.Namespace, meta.Name) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(annots) ttl := annotations.TTLFromAnnotations(annots, resource) for host, targets := range hostTargets { routeEndpoints = append(routeEndpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } log.Debugf("Endpoints generated from %s %s/%s: %v", src.rtKind, meta.Namespace, meta.Name, routeEndpoints) endpoints = append(endpoints, routeEndpoints...) } return MergeEndpoints(endpoints), nil } func namespacedName(namespace, name string) types.NamespacedName { return types.NamespacedName{Namespace: namespace, Name: name} } type gatewayRouteResolver struct { src *gatewayRouteSource gws map[types.NamespacedName]gatewayListeners nss map[string]*corev1.Namespace } type gatewayListeners struct { gateway *v1beta1.Gateway listeners map[v1.SectionName][]v1.Listener } func newGatewayRouteResolver(src *gatewayRouteSource, gateways []*v1beta1.Gateway, namespaces []*corev1.Namespace) *gatewayRouteResolver { // Create Gateway Listener lookup table. gws := make(map[types.NamespacedName]gatewayListeners, len(gateways)) for _, gw := range gateways { lss := make(map[v1.SectionName][]v1.Listener, len(gw.Spec.Listeners)+1) for i, lis := range gw.Spec.Listeners { lss[lis.Name] = gw.Spec.Listeners[i : i+1] } lss[""] = gw.Spec.Listeners gws[namespacedName(gw.Namespace, gw.Name)] = gatewayListeners{ gateway: gw, listeners: lss, } } // Create Namespace lookup table. nss := make(map[string]*corev1.Namespace, len(namespaces)) for _, ns := range namespaces { nss[ns.Name] = ns } return &gatewayRouteResolver{ src: src, gws: gws, nss: nss, } } func (c *gatewayRouteResolver) resolve(rt gatewayRoute) (map[string]endpoint.Targets, error) { rtHosts, err := c.hosts(rt) if err != nil { return nil, err } hostTargets := make(map[string]endpoint.Targets) routeParentRefs := rt.ParentRefs() if len(routeParentRefs) == 0 { log.Debugf("No parent references found for %s %s/%s", c.src.rtKind, rt.Metadata().Namespace, rt.Metadata().Name) return hostTargets, nil } meta := rt.Metadata() for _, rps := range rt.RouteStatus().Parents { // Confirm the Parent is the standard Gateway kind. ref := rps.ParentRef namespace := strVal((*string)(ref.Namespace), meta.Namespace) // Ensure that the parent reference is in the routeParentRefs list if !gwRouteHasParentRef(routeParentRefs, ref, meta) { log.Debugf("Parent reference %s/%s not found in routeParentRefs for %s %s/%s", namespace, string(ref.Name), c.src.rtKind, meta.Namespace, meta.Name) continue } group := strVal((*string)(ref.Group), gatewayGroup) kind := strVal((*string)(ref.Kind), gatewayKind) if group != gatewayGroup || kind != gatewayKind { log.Debugf("Unsupported parent %s/%s for %s %s/%s", group, kind, c.src.rtKind, meta.Namespace, meta.Name) continue } // Lookup the Gateway and its Listeners. gw, ok := c.gws[namespacedName(namespace, string(ref.Name))] if !ok { log.Debugf("Gateway %s/%s not found for %s %s/%s", namespace, ref.Name, c.src.rtKind, meta.Namespace, meta.Name) continue } // Confirm the Gateway has the correct name, if specified. if c.src.gwName != "" && c.src.gwName != gw.gateway.Name { log.Debugf("Gateway %s/%s does not match %s %s/%s", namespace, ref.Name, c.src.gwName, meta.Namespace, meta.Name) continue } // Confirm the Gateway has accepted the Route. if !gwRouteIsAccepted(rps.Conditions) { log.Debugf("Gateway %s/%s has not accepted the current generation %s %s/%s", namespace, ref.Name, c.src.rtKind, meta.Namespace, meta.Name) continue } // Match the Route to all possible Listeners. match := false section := sectionVal(ref.SectionName, "") listeners := gw.listeners[section] for i := range listeners { lis := &listeners[i] // Confirm that the Listener and Route protocols match. if !gwProtocolMatches(rt.Protocol(), lis.Protocol) { continue } // Confirm that the Listener and Route ports match, if specified. // EXPERIMENTAL: https://gateway-api.sigs.k8s.io/geps/gep-957/ if ref.Port != nil && *ref.Port != lis.Port { continue } // Confirm that the Listener allows the Route (based on namespace and kind). if !c.routeIsAllowed(gw.gateway, lis, rt) { continue } // Find all overlapping hostnames between the Route and Listener. // For {TCP,UDP}Routes, all annotation-generated hostnames should match since the Listener doesn't specify a hostname. // For {HTTP,TLS}Routes, hostnames (including any annotation-generated) will be required to match any Listeners specified hostname. gwHost := "" if lis.Hostname != nil { gwHost = string(*lis.Hostname) } for _, rtHost := range rtHosts { if gwHost == "" && rtHost == "" { // For {HTTP,TLS}Routes, this means the Route and the Listener both allow _any_ hostnames. // For {TCP,UDP}Routes, this should always happen since neither specifies hostnames. continue } host, ok := gwMatchingHost(gwHost, rtHost) if !ok { continue } override := annotations.TargetsFromTargetAnnotation(gw.gateway.Annotations) hostTargets[host] = append(hostTargets[host], override...) if len(override) == 0 { for _, addr := range gw.gateway.Status.Addresses { hostTargets[host] = append(hostTargets[host], addr.Value) } } match = true } } if !match { log.Debugf("Gateway %s/%s section %q does not match %s %s/%s hostnames %q", namespace, ref.Name, section, c.src.rtKind, meta.Namespace, meta.Name, rtHosts) } } // If a Gateway has multiple matching Listeners for the same host, then we'll // add its IPs to the target list multiple times and should dedupe them. for host, targets := range hostTargets { hostTargets[host] = uniqueTargets(targets) } return hostTargets, nil } func (c *gatewayRouteResolver) hosts(rt gatewayRoute) ([]string, error) { var hostnames []string for _, name := range rt.Hostnames() { hostnames = append(hostnames, string(name)) } // TODO: The combine-fqdn-annotation flag is similarly vague. if c.src.fqdnTemplate != nil && (len(hostnames) == 0 || c.src.combineFQDNAnnotation) { hosts, err := fqdn.ExecTemplate(c.src.fqdnTemplate, rt.Object()) if err != nil { return nil, err } hostnames = append(hostnames, hosts...) } hostNameAnnotation, hostNameAnnotationExists := rt.Metadata().Annotations[annotations.GatewayHostnameSourceKey] if !hostNameAnnotationExists { // This means that the route doesn't specify a hostname and should use any provided by // attached Gateway Listeners. This is only useful for {HTTP,TLS}Routes, but it doesn't // break {TCP,UDP}Routes. if len(rt.Hostnames()) == 0 { hostnames = append(hostnames, "") } if !c.src.ignoreHostnameAnnotation { hostnames = append(hostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...) } return hostnames, nil } switch strings.ToLower(hostNameAnnotation) { case gatewayHostnameSourceAnnotationOnlyValue: if c.src.ignoreHostnameAnnotation { return []string{}, nil } return annotations.HostnamesFromAnnotations(rt.Metadata().Annotations), nil case gatewayHostnameSourceDefinedHostsOnlyValue: // Explicitly use only defined hostnames (route spec and optional template result) return hostnames, nil default: // Invalid value provided: warn and fall back to default behavior (as if the annotation is absent) log.Warnf("Invalid value for %q on %s/%s: %q. Falling back to default behavior.", annotations.GatewayHostnameSourceKey, rt.Metadata().Namespace, rt.Metadata().Name, hostNameAnnotation) if len(rt.Hostnames()) == 0 { hostnames = append(hostnames, "") } if !c.src.ignoreHostnameAnnotation { hostnames = append(hostnames, annotations.HostnamesFromAnnotations(rt.Metadata().Annotations)...) } return hostnames, nil } } func (c *gatewayRouteResolver) routeIsAllowed(gw *v1beta1.Gateway, lis *v1.Listener, rt gatewayRoute) bool { meta := rt.Metadata() allow := lis.AllowedRoutes // Check the route's namespace. from := v1.NamespacesFromSame if allow != nil && allow.Namespaces != nil && allow.Namespaces.From != nil { from = *allow.Namespaces.From } switch from { case v1.NamespacesFromAll: // OK case v1.NamespacesFromSame: if gw.Namespace != meta.Namespace { return false } case v1.NamespacesFromSelector: selector, err := metav1.LabelSelectorAsSelector(allow.Namespaces.Selector) if err != nil { log.Debugf("Gateway %s/%s section %q has invalid namespace selector: %v", gw.Namespace, gw.Name, lis.Name, err) return false } // Get namespace. ns, ok := c.nss[meta.Namespace] if !ok { log.Errorf("Namespace not found for %s %s/%s", c.src.rtKind, meta.Namespace, meta.Name) return false } if !selector.Matches(labels.Set(ns.Labels)) { return false } default: log.Debugf("Gateway %s/%s section %q has unknown namespace from %q", gw.Namespace, gw.Name, lis.Name, from) return false } // Check the route's kind, if any are specified by the listener. // TODO: Do we need to consider SupportedKinds in the ListenerStatus instead of the Spec? // We only support core kinds and already check the protocol... Does this matter at all? if allow == nil || len(allow.Kinds) == 0 { return true } gvk := rt.Object().GetObjectKind().GroupVersionKind() for _, gk := range allow.Kinds { group := strVal((*string)(gk.Group), gatewayGroup) if gvk.Group == group && gvk.Kind == string(gk.Kind) { return true } } return false } func gwRouteHasParentRef(routeParentRefs []v1.ParentReference, ref v1.ParentReference, meta *metav1.ObjectMeta) bool { // Ensure that the parent reference is in the routeParentRefs list namespace := strVal((*string)(ref.Namespace), meta.Namespace) group := strVal((*string)(ref.Group), gatewayGroup) kind := strVal((*string)(ref.Kind), gatewayKind) for _, rpr := range routeParentRefs { rprGroup := strVal((*string)(rpr.Group), gatewayGroup) rprKind := strVal((*string)(rpr.Kind), gatewayKind) if rprGroup != group || rprKind != kind { continue } rprNamespace := strVal((*string)(rpr.Namespace), meta.Namespace) if string(rpr.Name) != string(ref.Name) || rprNamespace != namespace { continue } return true } return false } func gwRouteIsAccepted(conds []metav1.Condition) bool { for _, c := range conds { if v1.RouteConditionType(c.Type) == v1.RouteConditionAccepted { return c.Status == metav1.ConditionTrue } } return false } func uniqueTargets(targets endpoint.Targets) endpoint.Targets { if len(targets) < 2 { return targets } sort.Strings([]string(targets)) prev := targets[0] n := 1 for _, v := range targets[1:] { if v == prev { continue } prev = v targets[n] = v n++ } return targets[:n] } // gwProtocolMatches returns whether a and b are the same protocol, // where HTTP and HTTPS are considered the same. // and TLS and TCP are considered the same. func gwProtocolMatches(a, b v1.ProtocolType) bool { if a == v1.HTTPSProtocolType { a = v1.HTTPProtocolType } if b == v1.HTTPSProtocolType { b = v1.HTTPProtocolType } // if Listener is TLS and Route is TCP set Listener type to TCP as to pass true and return valid match if a == v1.TCPProtocolType && b == v1.TLSProtocolType { b = v1.TCPProtocolType } return a == b } // gwMatchingHost returns the most-specific overlapping host and a bool indicating if one was found. // Hostnames that are prefixed with a wildcard label (`*.`) are interpreted as a suffix match. // That means that "*.example.com" would match both "test.example.com" and "foo.test.example.com", // but not "example.com". An empty string matches anything. func gwMatchingHost(a, b string) (string, bool) { var ok bool if a, ok = gwHost(a); !ok { return "", false } if b, ok = gwHost(b); !ok { return "", false } if a == "" { return b, true } if b == "" || a == b { return a, true } if na, nb := len(a), len(b); nb < na || (na == nb && strings.HasPrefix(b, "*.")) { a, b = b, a } if strings.HasPrefix(a, "*.") && strings.HasSuffix(b, a[1:]) { return b, true } return "", false } // gwHost returns the canonical host and a value indicating if it's valid. func gwHost(host string) (string, bool) { if host == "" { return "", true } if isIPAddr(host) || !isDNS1123Domain(strings.TrimPrefix(host, "*.")) { return "", false } return toLowerCaseASCII(host), true } // isIPAddr returns whether s in an IP address. func isIPAddr(s string) bool { return endpoint.SuitableType(s) != endpoint.RecordTypeCNAME } // isDNS1123Domain returns whether s is a valid domain name according to RFC 1123. func isDNS1123Domain(s string) bool { if n := len(s); n == 0 || n > 255 { return false } for lbl, rest := "", s; rest != ""; { if lbl, rest, _ = strings.Cut(rest, "."); !isDNS1123Label(lbl) { return false } } return true } // isDNS1123Label returns whether s is a valid domain label according to RFC 1123. func isDNS1123Label(s string) bool { n := len(s) if n == 0 || n > 63 { return false } if !isAlphaNum(s[0]) || !isAlphaNum(s[n-1]) { return false } for i, k := 1, n-1; i < k; i++ { if b := s[i]; b != '-' && !isAlphaNum(b) { return false } } return true } func isAlphaNum(b byte) bool { switch { case 'a' <= b && b <= 'z', 'A' <= b && b <= 'Z', '0' <= b && b <= '9': return true default: return false } } func strVal(ptr *string, def string) string { if ptr == nil || *ptr == "" { return def } return *ptr } func sectionVal(ptr *v1.SectionName, def v1.SectionName) v1.SectionName { if ptr == nil || *ptr == "" { return def } return *ptr } func selectorsEqual(a, b labels.Selector) bool { if a == nil || b == nil { return a == b } aReq, aOK := a.DeepCopySelector().Requirements() bReq, bOK := b.DeepCopySelector().Requirements() if aOK != bOK || len(aReq) != len(bReq) { return false } sort.Stable(labels.ByKey(aReq)) sort.Stable(labels.ByKey(bReq)) for i, r := range aReq { if !r.Equal(bReq[i]) { return false } } return true } ================================================ FILE: source/gateway_grpcroute.go ================================================ /* Copyright 2022 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 source import ( "context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" v1 "sigs.k8s.io/gateway-api/apis/v1" informers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions" informers_v1 "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1" ) // NewGatewayGRPCRouteSource creates a new Gateway GRPCRoute source with the given config. func NewGatewayGRPCRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) { return newGatewayRouteSource(ctx, clients, config, "GRPCRoute", func(factory informers.SharedInformerFactory) gatewayRouteInformer { return &gatewayGRPCRouteInformer{factory.Gateway().V1().GRPCRoutes()} }) } type gatewayGRPCRoute struct{ route v1.GRPCRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion. func (rt *gatewayGRPCRoute) Object() kubeObject { return &rt.route } func (rt *gatewayGRPCRoute) Metadata() *metav1.ObjectMeta { return &rt.route.ObjectMeta } func (rt *gatewayGRPCRoute) Hostnames() []v1.Hostname { return rt.route.Spec.Hostnames } func (rt *gatewayGRPCRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs } func (rt *gatewayGRPCRoute) Protocol() v1.ProtocolType { return v1.HTTPSProtocolType } func (rt *gatewayGRPCRoute) RouteStatus() v1.RouteStatus { return rt.route.Status.RouteStatus } type gatewayGRPCRouteInformer struct { informers_v1.GRPCRouteInformer } func (inf gatewayGRPCRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) { list, err := inf.GRPCRouteInformer.Lister().GRPCRoutes(namespace).List(selector) if err != nil { return nil, err } routes := make([]gatewayRoute, len(list)) for i, rt := range list { // List results are supposed to be treated as read-only. // We make a shallow copy since we're only interested in setting the TypeMeta. clone := *rt clone.TypeMeta = metav1.TypeMeta{ APIVersion: v1.GroupVersion.String(), Kind: "GRPCRoute", } routes[i] = &gatewayGRPCRoute{clone} } return routes, nil } ================================================ FILE: source/gateway_grpcroute_test.go ================================================ /* Copyright 2022 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 source import ( "context" "testing" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubefake "k8s.io/client-go/kubernetes/fake" v1 "sigs.k8s.io/gateway-api/apis/v1" v1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) func TestGatewayGRPCRouteSourceEndpoints(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() gwClient := gatewayfake.NewSimpleClientset() kubeClient := kubefake.NewClientset() clients := new(MockClientGenerator) clients.On("GatewayClient").Return(gwClient, nil) clients.On("KubeClient").Return(kubeClient, nil) ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: "default", }, } _, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Namespace") ips := []string{"10.64.0.1", "10.64.0.2"} gw := &v1beta1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: "internal", Namespace: "default", }, Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPSProtocolType, }}, }, Status: gatewayStatus(ips...), } _, err = gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Gateway") rt := &v1.GRPCRoute{ ObjectMeta: metav1.ObjectMeta{ Name: "api", Namespace: "default", Annotations: map[string]string{ annotations.HostnameKey: "api-annotation.foobar.internal", }, }, Spec: v1.GRPCRouteSpec{ Hostnames: []v1.Hostname{"api-hostnames.foobar.internal"}, CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "internal"), }, }, }, Status: v1.GRPCRouteStatus{ RouteStatus: gwRouteStatus(gwParentRef("default", "internal")), }, } _, err = gwClient.GatewayV1().GRPCRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{}) require.NoError(t, err, "failed to create GRPCRoute") src, err := NewGatewayGRPCRouteSource(ctx, clients, &Config{ FQDNTemplate: "{{.Name}}-template.foobar.internal", CombineFQDNAndAnnotation: true, }) require.NoError(t, err, "failed to create Gateway GRPCRoute Source") endpoints, err := src.Endpoints(ctx) require.NoError(t, err, "failed to get Endpoints") validateEndpoints(t, endpoints, []*endpoint.Endpoint{ newTestEndpoint("api-annotation.foobar.internal", ips...), newTestEndpoint("api-hostnames.foobar.internal", ips...), newTestEndpoint("api-template.foobar.internal", ips...), }) } ================================================ FILE: source/gateway_hostname.go ================================================ // Copyright 2011 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // See: // - https://golang.org/LICENSE // - https://golang.org/src/crypto/x509/verify.go package source import ( "unicode/utf8" ) // TODO: refactor common DNS label functions into a shared package. // toLowerCaseASCII returns a lower-case version of in. See RFC 6125 6.4.1. We use // an explicitly ASCII function to avoid any sharp corners resulting from // performing Unicode operations on DNS labels. func toLowerCaseASCII(in string) string { // If the string is already lower-case then there's nothing to do. isAlreadyLowerCase := true for _, c := range in { if c == utf8.RuneError { // If we get a UTF-8 error then there might be // upper-case ASCII bytes in the invalid sequence. isAlreadyLowerCase = false break } if 'A' <= c && c <= 'Z' { isAlreadyLowerCase = false break } } if isAlreadyLowerCase { return in } out := []byte(in) for i, c := range out { if 'A' <= c && c <= 'Z' { out[i] += 'a' - 'A' } } return string(out) } ================================================ FILE: source/gateway_httproute.go ================================================ /* Copyright 2021 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 source import ( "context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" v1 "sigs.k8s.io/gateway-api/apis/v1" v1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" informers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions" informers_v1beta1 "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1beta1" ) // NewGatewayHTTPRouteSource creates a new Gateway HTTPRoute source with the given config. func NewGatewayHTTPRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) { return newGatewayRouteSource(ctx, clients, config, "HTTPRoute", func(factory informers.SharedInformerFactory) gatewayRouteInformer { return &gatewayHTTPRouteInformer{factory.Gateway().V1beta1().HTTPRoutes()} }) } type gatewayHTTPRoute struct{ route v1.HTTPRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion. func (rt *gatewayHTTPRoute) Object() kubeObject { return &rt.route } func (rt *gatewayHTTPRoute) Metadata() *metav1.ObjectMeta { return &rt.route.ObjectMeta } func (rt *gatewayHTTPRoute) Hostnames() []v1.Hostname { return rt.route.Spec.Hostnames } func (rt *gatewayHTTPRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs } func (rt *gatewayHTTPRoute) Protocol() v1.ProtocolType { return v1.HTTPProtocolType } func (rt *gatewayHTTPRoute) RouteStatus() v1.RouteStatus { return rt.route.Status.RouteStatus } type gatewayHTTPRouteInformer struct { informers_v1beta1.HTTPRouteInformer } func (inf gatewayHTTPRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) { list, err := inf.HTTPRouteInformer.Lister().HTTPRoutes(namespace).List(selector) if err != nil { return nil, err } routes := make([]gatewayRoute, len(list)) for i, rt := range list { // List results are supposed to be treated as read-only. // We make a shallow copy since we're only interested in setting the TypeMeta. clone := *rt clone.TypeMeta = metav1.TypeMeta{ APIVersion: v1beta1.GroupVersion.String(), Kind: "HTTPRoute", } routes[i] = &gatewayHTTPRoute{v1.HTTPRoute(clone)} } return routes, nil } ================================================ FILE: source/gateway_httproute_test.go ================================================ /* Copyright 2021 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 source import ( "context" "testing" "time" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" kubefake "k8s.io/client-go/kubernetes/fake" v1 "sigs.k8s.io/gateway-api/apis/v1" v1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" "sigs.k8s.io/external-dns/endpoint" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/source/annotations" ) func mustGetLabelSelector(s string) labels.Selector { v, err := getLabelSelector(s) if err != nil { panic(err) } return v } func gatewayStatus(ips ...string) v1.GatewayStatus { typ := v1.IPAddressType addrs := make([]v1.GatewayStatusAddress, len(ips)) for i, ip := range ips { addrs[i] = v1.GatewayStatusAddress{Type: &typ, Value: ip} } return v1.GatewayStatus{Addresses: addrs} } func httpRouteStatus(refs ...v1.ParentReference) v1.HTTPRouteStatus { return v1.HTTPRouteStatus{RouteStatus: gwRouteStatus(refs...)} } func gwRouteStatus(refs ...v1.ParentReference) v1.RouteStatus { var v v1.RouteStatus for _, ref := range refs { v.Parents = append(v.Parents, v1.RouteParentStatus{ ParentRef: ref, Conditions: []metav1.Condition{ { Type: string(v1.RouteConditionAccepted), Status: metav1.ConditionTrue, }, }, }) } return v } func omWithGeneration(meta metav1.ObjectMeta, generation int64) metav1.ObjectMeta { meta.Generation = generation return meta } func rsWithoutAccepted(routeStatus v1.HTTPRouteStatus) v1.HTTPRouteStatus { for _, parent := range routeStatus.Parents { for j := range parent.Conditions { cond := &parent.Conditions[j] if cond.Type == string(v1.RouteConditionAccepted) { cond.Type = "NotAccepted" // fake type to test for having no accepted condition } } } return routeStatus } func gwParentRef(namespace, name string, options ...gwParentRefOption) v1.ParentReference { group := v1.Group("gateway.networking.k8s.io") kind := v1.Kind("Gateway") ref := v1.ParentReference{ Group: &group, Kind: &kind, Name: v1.ObjectName(name), Namespace: (*v1.Namespace)(&namespace), } for _, opt := range options { opt(&ref) } return ref } type gwParentRefOption func(*v1.ParentReference) func withSectionName(name v1.SectionName) gwParentRefOption { return func(ref *v1.ParentReference) { ref.SectionName = &name } } func withPortNumber(port v1.PortNumber) gwParentRefOption { return func(ref *v1.ParentReference) { ref.Port = &port } } func newTestEndpoint(dnsName string, targets ...string) *endpoint.Endpoint { return newTestEndpointWithTTL(dnsName, endpoint.RecordTypeA, 0, targets...) } func newTestEndpointWithTTL(dnsName, recordType string, ttl int64, targets ...string) *endpoint.Endpoint { return &endpoint.Endpoint{ DNSName: dnsName, Targets: append([]string(nil), targets...), // clone targets RecordType: recordType, RecordTTL: endpoint.TTL(ttl), } } func TestGatewayHTTPRouteSourceEndpoints(t *testing.T) { fromAll := v1.NamespacesFromAll fromSame := v1.NamespacesFromSame fromSelector := v1.NamespacesFromSelector allowAllNamespaces := &v1.AllowedRoutes{ Namespaces: &v1.RouteNamespaces{ From: &fromAll, }, } objectMeta := func(namespace, name string) metav1.ObjectMeta { return metav1.ObjectMeta{ Name: name, Namespace: namespace, } } namespaces := func(names ...string) []*corev1.Namespace { v := make([]*corev1.Namespace, len(names)) for i, name := range names { v[i] = &corev1.Namespace{ObjectMeta: objectMeta("", name)} } return v } hostnames := func(names ...v1.Hostname) []v1.Hostname { return names } tests := []struct { title string config Config namespaces []*corev1.Namespace gateways []*v1beta1.Gateway routes []*v1beta1.HTTPRoute endpoints []*endpoint.Endpoint logExpectations []string }{ { title: "GatewayName", config: Config{ GatewayName: "gateway-name", }, namespaces: namespaces("gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("gateway-namespace", "gateway-name"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, { ObjectMeta: objectMeta("gateway-namespace", "not-gateway-name"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("2.3.4.5"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("route-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "gateway-name"), gwParentRef("gateway-namespace", "not-gateway-name"), }, }, }, Status: httpRouteStatus( // The route is attached to both gateways. gwParentRef("gateway-namespace", "gateway-name"), gwParentRef("gateway-namespace", "not-gateway-name"), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.example.internal", "1.2.3.4"), }, logExpectations: []string{ "Gateway gateway-namespace/not-gateway-name does not match gateway-name route-namespace/test", }, }, { title: "GatewayNameNoneAccepted", config: Config{ GatewayName: "gateway-name", }, namespaces: namespaces("gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: omWithGeneration(objectMeta("gateway-namespace", "gateway-name"), 2), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: omWithGeneration(objectMeta("route-namespace", "old-test"), 5), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "gateway-name"), }, }, }, Status: rsWithoutAccepted(httpRouteStatus(gwParentRef("gateway-namespace", "gateway-name"))), }}, endpoints: []*endpoint.Endpoint{}, logExpectations: []string{ "Gateway gateway-namespace/gateway-name has not accepted the current generation HTTPRoute route-namespace/old-test", }, }, { title: "GatewayNamespace", config: Config{ GatewayNamespace: "gateway-namespace", }, namespaces: namespaces("gateway-namespace", "not-gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("gateway-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, { ObjectMeta: objectMeta("not-gateway-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("2.3.4.5"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("route-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "test"), gwParentRef("not-gateway-namespace", "test"), }, }, }, Status: httpRouteStatus( // The route is attached to both gateways. gwParentRef("gateway-namespace", "test"), gwParentRef("not-gateway-namespace", "test"), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.example.internal", "1.2.3.4"), }, }, { title: "RouteNamespace", config: Config{ Namespace: "route-namespace", }, namespaces: namespaces("gateway-namespace", "route-namespace", "not-route-namespace"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("gateway-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: objectMeta("route-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("route-namespace.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("gateway-namespace", "test")), }, { ObjectMeta: objectMeta("not-route-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("not-route-namespace.example.internal"), }, Status: httpRouteStatus(gwParentRef("gateway-namespace", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("route-namespace.example.internal", "1.2.3.4"), }, }, { title: "GatewayLabelFilter", config: Config{ GatewayLabelFilter: "foo=bar", }, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "labels-match", Namespace: "default", Labels: map[string]string{"foo": "bar"}, }, Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }, { ObjectMeta: metav1.ObjectMeta{ Name: "labels-dont-match", Namespace: "default", Labels: map[string]string{"foo": "qux"}, }, Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("2.3.4.5"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "labels-match"), gwParentRef("default", "labels-dont-match"), }, }, }, Status: httpRouteStatus( // The route is attached to both gateways. gwParentRef("default", "labels-match"), gwParentRef("default", "labels-dont-match"), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.example.internal", "1.2.3.4"), }, }, { title: "RouteLabelFilter", config: Config{ LabelFilter: mustGetLabelSelector("foo=bar"), }, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: metav1.ObjectMeta{ Name: "labels-match", Namespace: "default", Labels: map[string]string{"foo": "bar"}, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("labels-match.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, { ObjectMeta: metav1.ObjectMeta{ Name: "labels-dont-match", Namespace: "default", Labels: map[string]string{"foo": "qux"}, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("labels-dont-match.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("labels-match.example.internal", "1.2.3.4"), }, }, { title: "RouteAnnotationFilter", config: Config{ AnnotationFilter: "foo=bar", }, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: metav1.ObjectMeta{ Name: "annotations-match", Namespace: "default", Annotations: map[string]string{"foo": "bar"}, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("annotations-match.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, { ObjectMeta: metav1.ObjectMeta{ Name: "annotations-dont-match", Namespace: "default", Annotations: map[string]string{"foo": "qux"}, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("annotations-dont-match.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("annotations-match.example.internal", "1.2.3.4"), }, }, { title: "SkipControllerAnnotation", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: metav1.ObjectMeta{ Name: "api", Namespace: "default", Annotations: map[string]string{ annotations.ControllerKey: "something-else", }, }, Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: hostnames("api.example.internal"), }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: nil, }, { title: "MultipleGateways", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("default", "one"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }, { ObjectMeta: objectMeta("default", "two"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("2.3.4.5"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "one"), gwParentRef("default", "two"), }, }, }, Status: httpRouteStatus( gwParentRef("default", "one"), gwParentRef("default", "two"), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.example.internal", "1.2.3.4", "2.3.4.5"), }, }, { title: "MultipleListeners", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "one"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{ { Name: "foo", Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("foo.example.internal"), }, { Name: "bar", Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("bar.example.internal"), }, }, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("*.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "one"), }, }, }, Status: httpRouteStatus( gwParentRef("default", "one"), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("foo.example.internal", "1.2.3.4"), newTestEndpoint("bar.example.internal", "1.2.3.4"), }, }, { title: "SectionNameMatch", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{ { Name: "foo", Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("foo.example.internal"), }, { Name: "bar", Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("bar.example.internal"), }, }, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("*.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test", withSectionName("foo")), }, }, }, Status: httpRouteStatus( gwParentRef("default", "test", withSectionName("foo")), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("foo.example.internal", "1.2.3.4"), }, }, { // EXPERIMENTAL: https://gateway-api.sigs.k8s.io/geps/gep-957/ title: "PortNumberMatch", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{ { Name: "foo", Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("foo.example.internal"), Port: 80, }, { Name: "bar", Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("bar.example.internal"), Port: 80, }, { Name: "qux", Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("qux.example.internal"), Port: 8080, }, }, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("*.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test", withPortNumber(80)), }, }, }, Status: httpRouteStatus( gwParentRef("default", "test", withPortNumber(80)), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("foo.example.internal", "1.2.3.4"), newTestEndpoint("bar.example.internal", "1.2.3.4"), }, }, { title: "WildcardInGateway", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("*.example.internal"), }}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "no-hostname"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: []v1.Hostname{ "foo.example.internal", }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("foo.example.internal", "1.2.3.4"), }, }, { title: "WildcardInRoute", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("foo.example.internal"), }}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "no-hostname"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: []v1.Hostname{ "*.example.internal", }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("foo.example.internal", "1.2.3.4"), }, }, { title: "WildcardInRouteAndGateway", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("*.example.internal"), }}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "no-hostname"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: []v1.Hostname{ "*.example.internal", }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("*.example.internal", "1.2.3.4"), }, }, { title: "NoRouteHostname", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, Hostname: hostnamePtr("foo.example.internal"), }}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "no-hostname"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: nil, }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("foo.example.internal", "1.2.3.4"), }, }, { title: "NoGateways", config: Config{}, namespaces: namespaces("default"), gateways: nil, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{}, }, }, Status: httpRouteStatus(), }}, endpoints: nil, }, { title: "NoHostnames", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "no-hostname"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: nil, }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: nil, }, { title: "HostnameAnnotation", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: metav1.ObjectMeta{ Name: "without-hostame", Namespace: "default", Annotations: map[string]string{ annotations.HostnameKey: "annotation.without-hostname.internal", }, }, Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: nil, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, { ObjectMeta: metav1.ObjectMeta{ Name: "with-hostame", Namespace: "default", Annotations: map[string]string{ annotations.HostnameKey: "annotation.with-hostname.internal", }, }, Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: hostnames("with-hostname.internal"), }, Status: httpRouteStatus(gwParentRef("default", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("annotation.without-hostname.internal", "1.2.3.4"), newTestEndpoint("annotation.with-hostname.internal", "1.2.3.4"), newTestEndpoint("with-hostname.internal", "1.2.3.4"), }, }, { title: "IgnoreHostnameAnnotation", config: Config{ IgnoreHostnameAnnotation: true, }, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: metav1.ObjectMeta{ Name: "with-hostame", Namespace: "default", Annotations: map[string]string{ annotations.HostnameKey: "annotation.with-hostname.internal", }, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("with-hostname.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("with-hostname.internal", "1.2.3.4"), }, }, { title: "FQDNTemplate", config: Config{ FQDNTemplate: "{{.Name}}.zero.internal, {{.Name}}.one.internal. , {{.Name}}.two.internal ", }, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: objectMeta("default", "fqdn-with-hostnames"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("fqdn-with-hostnames.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, { ObjectMeta: objectMeta("default", "fqdn-without-hostnames"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: nil, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("fqdn-without-hostnames.zero.internal", "1.2.3.4"), newTestEndpoint("fqdn-without-hostnames.one.internal", "1.2.3.4"), newTestEndpoint("fqdn-without-hostnames.two.internal", "1.2.3.4"), newTestEndpoint("fqdn-with-hostnames.internal", "1.2.3.4"), }, }, { title: "CombineFQDN", config: Config{ FQDNTemplate: "combine-{{.Name}}.internal", CombineFQDNAndAnnotation: true, }, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "fqdn-with-hostnames"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: hostnames("fqdn-with-hostnames.internal"), }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("fqdn-with-hostnames.internal", "1.2.3.4"), newTestEndpoint("combine-fqdn-with-hostnames.internal", "1.2.3.4"), }, }, { title: "TTL", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: metav1.ObjectMeta{ Name: "valid-ttl", Namespace: "default", Annotations: map[string]string{annotations.TtlKey: "15s"}, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("valid-ttl.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, { ObjectMeta: metav1.ObjectMeta{ Name: "invalid-ttl", Namespace: "default", Annotations: map[string]string{annotations.TtlKey: "abc"}, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("invalid-ttl.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("invalid-ttl.internal", "1.2.3.4"), newTestEndpointWithTTL("valid-ttl.internal", endpoint.RecordTypeA, 15, "1.2.3.4"), }, }, { title: "ProviderAnnotations", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: metav1.ObjectMeta{ Name: "provider-annotations", Namespace: "default", Annotations: map[string]string{ annotations.SetIdentifierKey: "test-set-identifier", annotations.AliasKey: "true", }, }, Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: hostnames("provider-annotations.com"), }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("provider-annotations.com", "1.2.3.4"). WithProviderSpecific("alias", "true"). WithSetIdentifier("test-set-identifier"), }, }, { title: "DifferentHostnameDifferentGateway", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("default", "one"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Hostname: hostnamePtr("*.one.internal"), Protocol: v1.HTTPProtocolType, }}, }, Status: gatewayStatus("1.2.3.4"), }, { ObjectMeta: objectMeta("default", "two"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Hostname: hostnamePtr("*.two.internal"), Protocol: v1.HTTPProtocolType, }}, }, Status: gatewayStatus("2.3.4.5"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "one"), gwParentRef("default", "two"), }, }, Hostnames: hostnames("test.one.internal", "test.two.internal"), }, Status: httpRouteStatus( gwParentRef("default", "one"), gwParentRef("default", "two"), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.one.internal", "1.2.3.4"), newTestEndpoint("test.two.internal", "2.3.4.5"), }, }, { title: "AllowedRoutesSameNamespace", config: Config{}, namespaces: namespaces("same-namespace", "other-namespace"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("same-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: &v1.AllowedRoutes{ Namespaces: &v1.RouteNamespaces{ From: &fromSame, }, }, }}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: objectMeta("same-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("same-namespace.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("same-namespace", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("same-namespace", "test")), }, { ObjectMeta: objectMeta("other-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("other-namespace.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("same-namespace", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("same-namespace", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("same-namespace.example.internal", "1.2.3.4"), }, }, { title: "AllowedRoutesNamespaceSelector", config: Config{}, namespaces: []*corev1.Namespace{ { ObjectMeta: metav1.ObjectMeta{ Name: "default", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "foo", Labels: map[string]string{"team": "foo"}, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "bar", Labels: map[string]string{"team": "bar"}, }, }, }, gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: &v1.AllowedRoutes{ Namespaces: &v1.RouteNamespaces{ From: &fromSelector, Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{"team": "foo"}, }, }, }, }}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: objectMeta("foo", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("foo.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, { ObjectMeta: objectMeta("bar", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("bar.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("foo.example.internal", "1.2.3.4"), }, }, { title: "MissingNamespace", config: Config{}, namespaces: nil, gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: &v1.AllowedRoutes{ Namespaces: &v1.RouteNamespaces{ // Namespace selector triggers namespace lookup. From: &fromSelector, Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{"foo": "bar"}, }, }, }, }}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.HTTPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, Hostnames: hostnames("example.internal"), }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: nil, }, { title: "AnnotationOverride", config: Config{ GatewayNamespace: "gateway-namespace", }, namespaces: namespaces("gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "overridden-gateway", Namespace: "gateway-namespace", Annotations: map[string]string{ annotations.TargetKey: "4.3.2.1", }, }, Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("route-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "overridden-gateway"), }, }, }, Status: httpRouteStatus( // The route is attached to both gateways. gwParentRef("gateway-namespace", "overridden-gateway"), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.example.internal", "4.3.2.1"), }, }, { title: "MutlipleGatewaysOneAnnotationOverride", config: Config{ GatewayNamespace: "gateway-namespace", }, namespaces: namespaces("gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "overridden-gateway", Namespace: "gateway-namespace", Annotations: map[string]string{ annotations.TargetKey: "4.3.2.1", }, }, Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, { ObjectMeta: objectMeta("gateway-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("2.3.4.5"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("route-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "overridden-gateway"), gwParentRef("gateway-namespace", "test"), }, }, }, Status: httpRouteStatus( gwParentRef("gateway-namespace", "overridden-gateway"), gwParentRef("gateway-namespace", "test"), ), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.example.internal", "4.3.2.1", "2.3.4.5"), }, }, { title: "MultipleGatewaysMultipleRoutes", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("default", "one"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }, { ObjectMeta: objectMeta("default", "two"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("2.3.4.5"), }, }, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: objectMeta("default", "one"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.one.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "one"), }, }, }, Status: httpRouteStatus( gwParentRef("default", "one"), ), }, { ObjectMeta: objectMeta("default", "two"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.two.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "two"), }, }, }, Status: httpRouteStatus( gwParentRef("default", "two"), ), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.one.internal", "1.2.3.4"), newTestEndpoint("test.two.internal", "2.3.4.5"), }, logExpectations: []string{ "Endpoints generated from HTTPRoute default/one: [test.one.internal 0 IN A 1.2.3.4 []]", "Endpoints generated from HTTPRoute default/two: [test.two.internal 0 IN A 2.3.4.5 []]", }, }, { title: "NoParentRefs", config: Config{ GatewayNamespace: "gateway-namespace", }, namespaces: namespaces("gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("gateway-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("route-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), }, Status: httpRouteStatus(gwParentRef("gateway-namespace", "test")), }}, endpoints: []*endpoint.Endpoint{}, logExpectations: []string{ "No parent references found for HTTPRoute route-namespace/test", }, }, { title: "ParentRefsMismatch", config: Config{ GatewayNamespace: "gateway-namespace", }, namespaces: namespaces("gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("gateway-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, }, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: objectMeta("route-namespace", "test"), Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "default-gateway"), }, }, }, Status: httpRouteStatus(gwParentRef("gateway-namespace", "other-gateway")), }}, endpoints: []*endpoint.Endpoint{}, logExpectations: []string{ "Parent reference gateway-namespace/other-gateway not found in routeParentRefs for HTTPRoute route-namespace/test", }, }, { title: "SourceAnnotation", config: Config{ GatewayNamespace: "gateway-namespace", }, namespaces: namespaces("gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("gateway-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, }, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: metav1.ObjectMeta{ Name: "route-test", Namespace: "test", Annotations: map[string]string{annotations.GatewayHostnameSourceKey: "defined-hosts-only", annotations.HostnameKey: "test.org.internal"}, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("gateway-namespace", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.example.internal", "1.2.3.4"), }, }, { title: "OnlyAnnotationHost", config: Config{ GatewayNamespace: "gateway-namespace", }, namespaces: namespaces("gateway-namespace", "route-namespace"), gateways: []*v1beta1.Gateway{ { ObjectMeta: objectMeta("gateway-namespace", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.HTTPProtocolType, AllowedRoutes: allowAllNamespaces, }}, }, Status: gatewayStatus("1.2.3.4"), }, }, routes: []*v1beta1.HTTPRoute{ { ObjectMeta: metav1.ObjectMeta{ Name: "route-test", Namespace: "test", Annotations: map[string]string{annotations.GatewayHostnameSourceKey: "annotation-only", annotations.HostnameKey: "test.org.internal"}, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("test.example.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("gateway-namespace", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("gateway-namespace", "test")), }, }, endpoints: []*endpoint.Endpoint{ newTestEndpoint("test.org.internal", "1.2.3.4"), }, }, { title: "InvalidSourceAnnotation", config: Config{}, namespaces: namespaces("default"), gateways: []*v1beta1.Gateway{{ ObjectMeta: objectMeta("default", "test"), Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{Protocol: v1.HTTPProtocolType}}, }, Status: gatewayStatus("1.2.3.4"), }}, routes: []*v1beta1.HTTPRoute{{ ObjectMeta: metav1.ObjectMeta{ Name: "invalid-annotation", Namespace: "default", Annotations: map[string]string{ annotations.GatewayHostnameSourceKey: "invalid-value", annotations.HostnameKey: "annotation.invalid.internal", }, }, Spec: v1.HTTPRouteSpec{ Hostnames: hostnames("route.invalid.internal"), CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "test"), }, }, }, Status: httpRouteStatus(gwParentRef("default", "test")), }}, endpoints: []*endpoint.Endpoint{ newTestEndpoint("route.invalid.internal", "1.2.3.4"), newTestEndpoint("annotation.invalid.internal", "1.2.3.4"), }, logExpectations: []string{ "Invalid value for \"external-dns.alpha.kubernetes.io/gateway-hostname-source\" on default/invalid-annotation: \"invalid-value\". Falling back to default behavior.", }, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { if len(tt.logExpectations) == 0 { t.Parallel() } ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() gwClient := gatewayfake.NewSimpleClientset() for _, gw := range tt.gateways { _, err := gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Gateway") } for _, rt := range tt.routes { _, err := gwClient.GatewayV1beta1().HTTPRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{}) require.NoError(t, err, "failed to create HTTPRoute") } kubeClient := kubefake.NewClientset() for _, ns := range tt.namespaces { _, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Namespace") } clients := new(MockClientGenerator) clients.On("GatewayClient").Return(gwClient, nil) clients.On("KubeClient").Return(kubeClient, nil) src, err := NewGatewayHTTPRouteSource(ctx, clients, &tt.config) require.NoError(t, err, "failed to create Gateway HTTPRoute Source") hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) endpoints, err := src.Endpoints(ctx) require.NoError(t, err, "failed to get Endpoints") validateEndpoints(t, endpoints, tt.endpoints) for _, msg := range tt.logExpectations { logtest.TestHelperLogContains(msg, hook, t) } }) } } func hostnamePtr(val v1.Hostname) *v1.Hostname { return &val } ================================================ FILE: source/gateway_tcproute.go ================================================ /* Copyright 2021 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 source import ( "context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" informers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions" informers_v1a2 "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1alpha2" ) // NewGatewayTCPRouteSource creates a new Gateway TCPRoute source with the given config. func NewGatewayTCPRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) { return newGatewayRouteSource(ctx, clients, config, "TCPRoute", func(factory informers.SharedInformerFactory) gatewayRouteInformer { return &gatewayTCPRouteInformer{factory.Gateway().V1alpha2().TCPRoutes()} }) } type gatewayTCPRoute struct{ route v1alpha2.TCPRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion. func (rt *gatewayTCPRoute) Object() kubeObject { return &rt.route } func (rt *gatewayTCPRoute) Metadata() *metav1.ObjectMeta { return &rt.route.ObjectMeta } func (rt *gatewayTCPRoute) Hostnames() []v1.Hostname { return nil } func (rt *gatewayTCPRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs } func (rt *gatewayTCPRoute) Protocol() v1.ProtocolType { return v1.TCPProtocolType } func (rt *gatewayTCPRoute) RouteStatus() v1.RouteStatus { return rt.route.Status.RouteStatus } type gatewayTCPRouteInformer struct { informers_v1a2.TCPRouteInformer } func (inf gatewayTCPRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) { list, err := inf.TCPRouteInformer.Lister().TCPRoutes(namespace).List(selector) if err != nil { return nil, err } routes := make([]gatewayRoute, len(list)) for i, rt := range list { // List results are supposed to be treated as read-only. // We make a shallow copy since we're only interested in setting the TypeMeta. clone := *rt clone.TypeMeta = metav1.TypeMeta{ APIVersion: v1alpha2.GroupVersion.String(), Kind: "TCPRoute", } routes[i] = &gatewayTCPRoute{clone} } return routes, nil } ================================================ FILE: source/gateway_tcproute_test.go ================================================ /* Copyright 2021 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 source import ( "context" "testing" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubefake "k8s.io/client-go/kubernetes/fake" v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" v1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) func TestGatewayTCPRouteSourceEndpoints(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() gwClient := gatewayfake.NewSimpleClientset() kubeClient := kubefake.NewClientset() clients := new(MockClientGenerator) clients.On("GatewayClient").Return(gwClient, nil) clients.On("KubeClient").Return(kubeClient, nil) ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: "default", }, } _, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Namespace") ips := []string{"10.64.0.1", "10.64.0.2"} gw := &v1beta1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: "internal", Namespace: "default", }, Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.TCPProtocolType, }}, }, Status: gatewayStatus(ips...), } _, err = gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Gateway") rt := &v1alpha2.TCPRoute{ ObjectMeta: metav1.ObjectMeta{ Name: "api", Namespace: "default", Annotations: map[string]string{ annotations.HostnameKey: "api-annotation.foobar.internal", }, }, Spec: v1alpha2.TCPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "internal"), }, }, }, Status: v1alpha2.TCPRouteStatus{ RouteStatus: gwRouteStatus(gwParentRef("default", "internal")), }, } _, err = gwClient.GatewayV1alpha2().TCPRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{}) require.NoError(t, err, "failed to create TCPRoute") src, err := NewGatewayTCPRouteSource(ctx, clients, &Config{ FQDNTemplate: "{{.Name}}-template.foobar.internal", CombineFQDNAndAnnotation: true, }) require.NoError(t, err, "failed to create Gateway TCPRoute Source") endpoints, err := src.Endpoints(ctx) require.NoError(t, err, "failed to get Endpoints") validateEndpoints(t, endpoints, []*endpoint.Endpoint{ newTestEndpoint("api-annotation.foobar.internal", ips...), newTestEndpoint("api-template.foobar.internal", ips...), }) } ================================================ FILE: source/gateway_test.go ================================================ /* Copyright 2023 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 source import ( "strings" "testing" v1 "sigs.k8s.io/gateway-api/apis/v1" ) func TestGatewayMatchingHost(t *testing.T) { tests := []struct { desc string a, b string host string ok bool }{ { desc: "ipv4-rejected", a: "1.2.3.4", ok: false, }, { desc: "ipv6-rejected", a: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", ok: false, }, { desc: "empty-matches-empty", ok: true, }, { desc: "empty-matches-nonempty", a: "example.net", host: "example.net", ok: true, }, { desc: "simple-match", a: "example.net", b: "example.net", host: "example.net", ok: true, }, { desc: "wildcard-matches-longer", a: "*.example.net", b: "test.example.net", host: "test.example.net", ok: true, }, { desc: "wildcard-matches-equal-length", a: "*.example.net", b: "a.example.net", host: "a.example.net", ok: true, }, { desc: "wildcard-matches-multiple-subdomains", a: "*.example.net", b: "foo.bar.test.example.net", host: "foo.bar.test.example.net", ok: true, }, { desc: "wildcard-doesnt-match-parent", a: "*.example.net", b: "example.net", ok: false, }, { desc: "wildcard-must-be-complete-label", a: "*example.net", b: "test.example.net", ok: false, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { for range 2 { if host, ok := gwMatchingHost(tt.a, tt.b); host != tt.host || ok != tt.ok { t.Errorf( "gwMatchingHost(%q, %q); got: %q, %v; want: %q, %v", tt.a, tt.b, host, ok, tt.host, tt.ok, ) } tt.a, tt.b = tt.b, tt.a } }) } } func TestGatewayMatchingProtocol(t *testing.T) { tests := []struct { route, lis string desc string ok bool }{ { desc: "protocol-matches-lis-https-route-http", route: "HTTP", lis: "HTTPS", ok: true, }, { desc: "protocol-match-invalid-list-https-route-tcp", route: "TCP", lis: "HTTPS", ok: false, }, { desc: "protocol-match-valid-lis-tls-route-tls", route: "TLS", lis: "TLS", ok: true, }, { desc: "protocol-match-valid-lis-TLS-route-TCP", route: "TCP", lis: "TLS", ok: true, }, { desc: "protocol-match-valid-lis-TLS-route-TCP", route: "TLS", lis: "TCP", ok: false, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { for range 2 { if ok := gwProtocolMatches(v1.ProtocolType(tt.route), v1.ProtocolType(tt.lis)); ok != tt.ok { t.Errorf( "gwProtocolMatches(%q, %q); got: %v; want: %v", tt.route, tt.lis, ok, tt.ok, ) } // tt.a, tt.b = tt.b, tt.a } }) } } func TestIsDNS1123Domain(t *testing.T) { tests := []struct { desc string in string ok bool }{ { desc: "empty", ok: false, }, { desc: "label-too-long", in: strings.Repeat("x", 64) + ".example.net", ok: false, }, { desc: "domain-too-long", in: strings.Repeat("testing.", 256/(len("testing."))) + "example.net", ok: false, }, { desc: "hostname", in: "example", ok: true, }, { desc: "domain", in: "example.net", ok: true, }, { desc: "subdomain", in: "test.example.net", ok: true, }, { desc: "dashes", in: "test-with-dash.example.net", ok: true, }, { desc: "dash-prefix", in: "-dash-prefix.example.net", ok: false, }, { desc: "dash-suffix", in: "dash-suffix-.example.net", ok: false, }, { desc: "underscore", in: "under_score.example.net", ok: false, }, { desc: "plus", in: "pl+us.example.net", ok: false, }, { desc: "brackets", in: "bra[k]ets.example.net", ok: false, }, { desc: "parens", in: "pa[re]ns.example.net", ok: false, }, { desc: "wild", in: "*.example.net", ok: false, }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { if ok := isDNS1123Domain(tt.in); ok != tt.ok { t.Errorf("isDNS1123Domain(%q); got: %v; want: %v", tt.in, ok, tt.ok) } }) } } ================================================ FILE: source/gateway_tlsroute.go ================================================ /* Copyright 2021 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 source import ( "context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" informers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions" informers_v1a2 "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1alpha2" ) // NewGatewayTLSRouteSource creates a new Gateway TLSRoute source with the given config. func NewGatewayTLSRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) { return newGatewayRouteSource(ctx, clients, config, "TLSRoute", func(factory informers.SharedInformerFactory) gatewayRouteInformer { return &gatewayTLSRouteInformer{factory.Gateway().V1alpha2().TLSRoutes()} }) } type gatewayTLSRoute struct{ route v1alpha2.TLSRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion. func (rt *gatewayTLSRoute) Object() kubeObject { return &rt.route } func (rt *gatewayTLSRoute) Metadata() *metav1.ObjectMeta { return &rt.route.ObjectMeta } func (rt *gatewayTLSRoute) Hostnames() []v1.Hostname { return rt.route.Spec.Hostnames } func (rt *gatewayTLSRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs } func (rt *gatewayTLSRoute) Protocol() v1.ProtocolType { return v1.TLSProtocolType } func (rt *gatewayTLSRoute) RouteStatus() v1.RouteStatus { return rt.route.Status.RouteStatus } type gatewayTLSRouteInformer struct { informers_v1a2.TLSRouteInformer } func (inf gatewayTLSRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) { list, err := inf.TLSRouteInformer.Lister().TLSRoutes(namespace).List(selector) if err != nil { return nil, err } routes := make([]gatewayRoute, len(list)) for i, rt := range list { // List results are supposed to be treated as read-only. // We make a shallow copy since we're only interested in setting the TypeMeta. clone := *rt clone.TypeMeta = metav1.TypeMeta{ APIVersion: v1alpha2.GroupVersion.String(), Kind: "TLSRoute", } routes[i] = &gatewayTLSRoute{clone} } return routes, nil } ================================================ FILE: source/gateway_tlsroute_test.go ================================================ /* Copyright 2021 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 source import ( "context" "testing" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubefake "k8s.io/client-go/kubernetes/fake" v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" v1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) func TestGatewayTLSRouteSourceEndpoints(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() gwClient := gatewayfake.NewSimpleClientset() kubeClient := kubefake.NewClientset() clients := new(MockClientGenerator) clients.On("GatewayClient").Return(gwClient, nil) clients.On("KubeClient").Return(kubeClient, nil) ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: "default", }, } _, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Namespace") ips := []string{"10.64.0.1", "10.64.0.2"} gw := &v1beta1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: "internal", Namespace: "default", }, Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.TLSProtocolType, }}, }, Status: gatewayStatus(ips...), } _, err = gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Gateway") rt := &v1alpha2.TLSRoute{ ObjectMeta: metav1.ObjectMeta{ Name: "api", Namespace: "default", Annotations: map[string]string{ annotations.HostnameKey: "api-annotation.foobar.internal", }, }, Spec: v1alpha2.TLSRouteSpec{ Hostnames: []v1.Hostname{"api-hostnames.foobar.internal"}, CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "internal"), }, }, }, Status: v1alpha2.TLSRouteStatus{ RouteStatus: gwRouteStatus(gwParentRef("default", "internal")), }, } _, err = gwClient.GatewayV1alpha2().TLSRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{}) require.NoError(t, err, "failed to create TLSRoute") src, err := NewGatewayTLSRouteSource(ctx, clients, &Config{ FQDNTemplate: "{{.Name}}-template.foobar.internal", CombineFQDNAndAnnotation: true, }) require.NoError(t, err, "failed to create Gateway TLSRoute Source") endpoints, err := src.Endpoints(ctx) require.NoError(t, err, "failed to get Endpoints") validateEndpoints(t, endpoints, []*endpoint.Endpoint{ newTestEndpoint("api-annotation.foobar.internal", ips...), newTestEndpoint("api-hostnames.foobar.internal", ips...), newTestEndpoint("api-template.foobar.internal", ips...), }) } ================================================ FILE: source/gateway_udproute.go ================================================ /* Copyright 2021 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 source import ( "context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" informers "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions" informers_v1a2 "sigs.k8s.io/gateway-api/pkg/client/informers/externalversions/apis/v1alpha2" ) // NewGatewayUDPRouteSource creates a new Gateway UDPRoute source with the given config. func NewGatewayUDPRouteSource(ctx context.Context, clients ClientGenerator, config *Config) (Source, error) { return newGatewayRouteSource(ctx, clients, config, "UDPRoute", func(factory informers.SharedInformerFactory) gatewayRouteInformer { return &gatewayUDPRouteInformer{factory.Gateway().V1alpha2().UDPRoutes()} }) } type gatewayUDPRoute struct{ route v1alpha2.UDPRoute } // NOTE: Must update TypeMeta in List when changing the APIVersion. func (rt *gatewayUDPRoute) Object() kubeObject { return &rt.route } func (rt *gatewayUDPRoute) Metadata() *metav1.ObjectMeta { return &rt.route.ObjectMeta } func (rt *gatewayUDPRoute) Hostnames() []v1.Hostname { return nil } func (rt *gatewayUDPRoute) ParentRefs() []v1.ParentReference { return rt.route.Spec.ParentRefs } func (rt *gatewayUDPRoute) Protocol() v1.ProtocolType { return v1.UDPProtocolType } func (rt *gatewayUDPRoute) RouteStatus() v1.RouteStatus { return rt.route.Status.RouteStatus } type gatewayUDPRouteInformer struct { informers_v1a2.UDPRouteInformer } func (inf gatewayUDPRouteInformer) List(namespace string, selector labels.Selector) ([]gatewayRoute, error) { list, err := inf.UDPRouteInformer.Lister().UDPRoutes(namespace).List(selector) if err != nil { return nil, err } routes := make([]gatewayRoute, len(list)) for i, rt := range list { // List results are supposed to be treated as read-only. // We make a shallow copy since we're only interested in setting the TypeMeta. clone := *rt clone.TypeMeta = metav1.TypeMeta{ APIVersion: v1alpha2.GroupVersion.String(), Kind: "UDPRoute", } routes[i] = &gatewayUDPRoute{clone} } return routes, nil } ================================================ FILE: source/gateway_udproute_test.go ================================================ /* Copyright 2021 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 source import ( "context" "testing" "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubefake "k8s.io/client-go/kubernetes/fake" v1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" v1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" gatewayfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) func TestGatewayUDPRouteSourceEndpoints(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) defer cancel() gwClient := gatewayfake.NewSimpleClientset() kubeClient := kubefake.NewClientset() clients := new(MockClientGenerator) clients.On("GatewayClient").Return(gwClient, nil) clients.On("KubeClient").Return(kubeClient, nil) ns := &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: "default", }, } _, err := kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Namespace") ips := []string{"10.64.0.1", "10.64.0.2"} gw := &v1beta1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: "internal", Namespace: "default", }, Spec: v1.GatewaySpec{ Listeners: []v1.Listener{{ Protocol: v1.UDPProtocolType, }}, }, Status: gatewayStatus(ips...), } _, err = gwClient.GatewayV1beta1().Gateways(gw.Namespace).Create(ctx, gw, metav1.CreateOptions{}) require.NoError(t, err, "failed to create Gateway") rt := &v1alpha2.UDPRoute{ ObjectMeta: metav1.ObjectMeta{ Name: "api", Namespace: "default", Annotations: map[string]string{ annotations.HostnameKey: "api-annotation.foobar.internal", }, }, Spec: v1alpha2.UDPRouteSpec{ CommonRouteSpec: v1.CommonRouteSpec{ ParentRefs: []v1.ParentReference{ gwParentRef("default", "internal"), }, }, }, Status: v1alpha2.UDPRouteStatus{ RouteStatus: gwRouteStatus(gwParentRef("default", "internal")), }, } _, err = gwClient.GatewayV1alpha2().UDPRoutes(rt.Namespace).Create(ctx, rt, metav1.CreateOptions{}) require.NoError(t, err, "failed to create UDPRoute") src, err := NewGatewayUDPRouteSource(ctx, clients, &Config{ FQDNTemplate: "{{.Name}}-template.foobar.internal", CombineFQDNAndAnnotation: true, }) require.NoError(t, err, "failed to create Gateway UDPRoute Source") endpoints, err := src.Endpoints(ctx) require.NoError(t, err, "failed to get Endpoints") validateEndpoints(t, endpoints, []*endpoint.Endpoint{ newTestEndpoint("api-annotation.foobar.internal", ips...), newTestEndpoint("api-template.foobar.internal", ips...), }) } ================================================ FILE: source/gloo_proxy.go ================================================ /* Copyright 2020n 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 source import ( "context" "encoding/json" "fmt" "maps" "strings" log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/kubernetes" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" netinformers "k8s.io/client-go/informers/networking/v1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/informers" ) var ( proxyGVR = schema.GroupVersionResource{ Group: "gloo.solo.io", Version: "v1", Resource: "proxies", } virtualServiceGVR = schema.GroupVersionResource{ Group: "gateway.solo.io", Version: "v1", Resource: "virtualservices", } gatewayGVR = schema.GroupVersionResource{ Group: "gateway.solo.io", Version: "v1", Resource: "gateways", } ) // Basic redefinition of "Proxy" CRD : https://github.com/solo-io/gloo/blob/v1.4.6/projects/gloo/pkg/api/v1/proxy.pb.go type proxy struct { metav1.TypeMeta `json:",inline"` Metadata metav1.ObjectMeta `json:"metadata"` Spec proxySpec `json:"spec"` } type proxySpec struct { Listeners []proxySpecListener `json:"listeners,omitempty"` } type proxySpecListener struct { HTTPListener proxySpecHTTPListener `json:"httpListener"` MetadataStatic proxyMetadataStatic `json:"metadataStatic"` } type proxyMetadataStatic struct { Source []proxyMetadataStaticSource `json:"sources,omitempty"` } type proxyMetadataStaticSource struct { ResourceKind string `json:"resourceKind,omitempty"` ResourceRef proxyMetadataStaticSourceResourceRef `json:"resourceRef"` } type proxyMetadataStaticSourceResourceRef struct { Name string `json:"name,omitempty"` Namespace string `json:"namespace,omitempty"` } type proxySpecHTTPListener struct { VirtualHosts []proxyVirtualHost `json:"virtualHosts,omitempty"` } type proxyVirtualHost struct { Domains []string `json:"domains,omitempty"` Metadata proxyVirtualHostMetadata `json:"metadata"` MetadataStatic proxyVirtualHostMetadataStatic `json:"metadataStatic"` } type proxyVirtualHostMetadata struct { Source []proxyVirtualHostMetadataSource `json:"sources,omitempty"` } type proxyVirtualHostMetadataStatic struct { Source []proxyVirtualHostMetadataStaticSource `json:"sources"` } type proxyVirtualHostMetadataSource struct { Kind string `json:"kind,omitempty"` Name string `json:"name,omitempty"` Namespace string `json:"namespace,omitempty"` } type proxyVirtualHostMetadataStaticSource struct { ResourceKind string `json:"resourceKind"` ResourceRef proxyVirtualHostMetadataSourceResourceRef `json:"resourceRef"` } type proxyVirtualHostMetadataSourceResourceRef struct { proxyVirtualHost Name string `json:"name,omitempty"` Namespace string `json:"namespace,omitempty"` } // glooSource is an implementation of Source for Gloo Proxy objects. // // +externaldns:source:name=gloo-proxy // +externaldns:source:category=Service Mesh // +externaldns:source:description=Creates DNS entries from Gloo Proxy resources // +externaldns:source:resources=Proxy.gloo.solo.io // +externaldns:source:filters= // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=false // +externaldns:source:provider-specific=true type glooSource struct { serviceInformer coreinformers.ServiceInformer ingressInformer netinformers.IngressInformer proxyInformer kubeinformers.GenericInformer virtualServiceInformer kubeinformers.GenericInformer gatewayInformer kubeinformers.GenericInformer // TODO: glooNamespaces is the list of namespaces to scan for Gloo Proxies. All namespace access is still required glooNamespaces []string } // NewGlooSource creates a new glooSource with the given config func NewGlooSource( ctx context.Context, dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, cfg *Config) (Source, error) { informerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, 0) serviceInformer := informerFactory.Core().V1().Services() ingressInformer := informerFactory.Networking().V1().Ingresses() _, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = ingressInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) dynamicInformerFactory := dynamicinformer.NewDynamicSharedInformerFactory(dynamicKubeClient, 0) proxyInformer := dynamicInformerFactory.ForResource(proxyGVR) virtualServiceInformer := dynamicInformerFactory.ForResource(virtualServiceGVR) gatewayInformer := dynamicInformerFactory.ForResource(gatewayGVR) _, _ = proxyInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = virtualServiceInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = gatewayInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) dynamicInformerFactory.Start(ctx.Done()) if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } if err := informers.WaitForDynamicCacheSync(ctx, dynamicInformerFactory); err != nil { return nil, err } return &glooSource{ serviceInformer, ingressInformer, proxyInformer, virtualServiceInformer, gatewayInformer, cfg.GlooNamespaces, }, nil } func (gs *glooSource) AddEventHandler(_ context.Context, _ func()) { } // Endpoints returns endpoint objects func (gs *glooSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { endpoints := []*endpoint.Endpoint{} for _, ns := range gs.glooNamespaces { proxyObjects, err := gs.proxyInformer.Lister().ByNamespace(ns).List(labels.Everything()) if err != nil { return nil, err } for _, obj := range proxyObjects { unstructuredObj, ok := obj.(*unstructured.Unstructured) if !ok { return nil, err } jsonData, err := json.Marshal(unstructuredObj.Object) if err != nil { return nil, err } var proxy proxy if err = json.Unmarshal(jsonData, &proxy); err != nil { return nil, err } log.Debugf("Gloo: Find %s proxy", proxy.Metadata.Name) proxyTargets := annotations.TargetsFromTargetAnnotation(proxy.Metadata.Annotations) if len(proxyTargets) == 0 { proxyTargets, err = gs.targetsFromGatewayIngress(&proxy) if err != nil { return nil, err } } if len(proxyTargets) == 0 { proxyTargets, err = gs.proxyTargets(proxy.Metadata.Name, ns) if err != nil { return nil, err } } log.Debugf("Gloo[%s]: Find %d target(s) (%+v)", proxy.Metadata.Name, len(proxyTargets), proxyTargets) proxyEndpoints, err := gs.generateEndpointsFromProxy(&proxy, proxyTargets) if err != nil { return nil, err } log.Debugf("Gloo[%s]: Generate %d endpoint(s)", proxy.Metadata.Name, len(proxyEndpoints)) endpoints = append(endpoints, proxyEndpoints...) } } return MergeEndpoints(endpoints), nil } func (gs *glooSource) generateEndpointsFromProxy(proxy *proxy, targets endpoint.Targets) ([]*endpoint.Endpoint, error) { endpoints := []*endpoint.Endpoint{} resource := fmt.Sprintf("proxy/%s/%s", proxy.Metadata.Namespace, proxy.Metadata.Name) for _, listener := range proxy.Spec.Listeners { for _, virtualHost := range listener.HTTPListener.VirtualHosts { ants, err := gs.annotationsFromProxySource(virtualHost) if err != nil { return nil, err } ttl := annotations.TTLFromAnnotations(ants, resource) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ants) for _, domain := range virtualHost.Domains { endpoints = append(endpoints, endpoint.EndpointsForHostname(strings.TrimSuffix(domain, "."), targets, ttl, providerSpecific, setIdentifier, "")...) } } } return endpoints, nil } func (gs *glooSource) annotationsFromProxySource(virtualHost proxyVirtualHost) (map[string]string, error) { ants := map[string]string{} for _, src := range virtualHost.Metadata.Source { if src.Kind != "*v1.VirtualService" { log.Debugf("Unsupported listener source. Expecting '*v1.VirtualService', got (%s)", src.Kind) continue } virtualServiceObj, err := gs.virtualServiceInformer.Lister().ByNamespace(src.Namespace).Get(src.Name) if err != nil { return nil, err } unstructuredVirtualService, ok := virtualServiceObj.(*unstructured.Unstructured) if !ok { log.Error("unexpected object: it is not *unstructured.Unstructured") continue } maps.Copy(ants, unstructuredVirtualService.GetAnnotations()) } for _, src := range virtualHost.MetadataStatic.Source { if src.ResourceKind != "*v1.VirtualService" { log.Debugf("Unsupported listener source. Expecting '*v1.VirtualService', got (%s)", src.ResourceKind) continue } virtualServiceObj, err := gs.virtualServiceInformer.Lister().ByNamespace(src.ResourceRef.Namespace).Get(src.ResourceRef.Name) if err != nil { return nil, err } unstructuredVirtualService, ok := virtualServiceObj.(*unstructured.Unstructured) if !ok { log.Error("unexpected object: it is not *unstructured.Unstructured") continue } maps.Copy(ants, unstructuredVirtualService.GetAnnotations()) } return ants, nil } func (gs *glooSource) proxyTargets(name string, namespace string) (endpoint.Targets, error) { svc, err := gs.serviceInformer.Lister().Services(namespace).Get(name) if err != nil { return nil, err } var targets endpoint.Targets switch svc.Spec.Type { case corev1.ServiceTypeLoadBalancer: for _, lb := range svc.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } default: log.WithField("gateway", name).WithField("service", svc).Warn("Gloo: Proxy service type not supported") } return targets, nil } func (gs *glooSource) targetsFromGatewayIngress(proxy *proxy) (endpoint.Targets, error) { targets := make(endpoint.Targets, 0) for _, listener := range proxy.Spec.Listeners { for _, source := range listener.MetadataStatic.Source { if source.ResourceKind != "*v1.Gateway" { log.Debugf("Unsupported listener source. Expecting '*v1.Gateway', got (%s)", source.ResourceKind) continue } gatewayObj, err := gs.gatewayInformer.Lister().ByNamespace(source.ResourceRef.Namespace).Get(source.ResourceRef.Name) if err != nil { return nil, err } unstructuredGateway, ok := gatewayObj.(*unstructured.Unstructured) if !ok { log.Error("unexpected object: it is not *unstructured.Unstructured") continue } if ingressStr, ok := unstructuredGateway.GetAnnotations()[annotations.Ingress]; ok && ingressStr != "" { namespace, name, err := ParseIngress(ingressStr) if err != nil { return nil, fmt.Errorf("failed to parse Ingress annotation on Gateway (%s/%s): %w", unstructuredGateway.GetNamespace(), unstructuredGateway.GetName(), err) } if namespace == "" { namespace = unstructuredGateway.GetNamespace() } ingress, err := gs.ingressInformer.Lister().Ingresses(namespace).Get(name) if err != nil { return nil, err } for _, lb := range ingress.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } else if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } } } } return targets, nil } ================================================ FILE: source/gloo_proxy_test.go ================================================ /* Copyright 2020n 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 source import ( "encoding/json" "fmt" "testing" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" 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/runtime/schema" fakeDynamic "k8s.io/client-go/dynamic/fake" fakeKube "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" ) // This is a compile-time validation that glooSource is a Source. var _ Source = &glooSource{} const defaultGlooNamespace = "gloo-system" // Internal proxy test var internalProxy = proxy{ TypeMeta: metav1.TypeMeta{ APIVersion: proxyGVR.GroupVersion().String(), Kind: "Proxy", }, Metadata: metav1.ObjectMeta{ Name: "internal", Namespace: defaultGlooNamespace, }, Spec: proxySpec{ Listeners: []proxySpecListener{ { HTTPListener: proxySpecHTTPListener{ VirtualHosts: []proxyVirtualHost{ { Domains: []string{"a.test", "b.test"}, Metadata: proxyVirtualHostMetadata{ Source: []proxyVirtualHostMetadataSource{ { Kind: "*v1.Unknown", Name: "my-unknown-svc", Namespace: "unknown", }, }, }, }, { Domains: []string{"c.test"}, Metadata: proxyVirtualHostMetadata{ Source: []proxyVirtualHostMetadataSource{ { Kind: "*v1.VirtualService", Name: "my-internal-svc", Namespace: "internal", }, }, }, }, }, }, }, }, }, } var internalProxySvc = corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: internalProxy.Metadata.Name, Namespace: internalProxy.Metadata.Namespace, }, Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeLoadBalancer, }, Status: corev1.ServiceStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { IP: "203.0.113.1", }, { IP: "203.0.113.2", }, { IP: "203.0.113.3", }, }, }, }, } var internalProxySource = metav1.PartialObjectMetadata{ TypeMeta: metav1.TypeMeta{ APIVersion: virtualServiceGVR.GroupVersion().String(), Kind: "VirtualService", }, ObjectMeta: metav1.ObjectMeta{ Name: internalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Name, Namespace: internalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Namespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "42", "external-dns.alpha.kubernetes.io/aws-geolocation-country-code": "LU", "external-dns.alpha.kubernetes.io/set-identifier": "identifier", }, }, } // External proxy test var externalProxy = proxy{ TypeMeta: metav1.TypeMeta{ APIVersion: proxyGVR.GroupVersion().String(), Kind: "Proxy", }, Metadata: metav1.ObjectMeta{ Name: "external", Namespace: defaultGlooNamespace, }, Spec: proxySpec{ Listeners: []proxySpecListener{ { HTTPListener: proxySpecHTTPListener{ VirtualHosts: []proxyVirtualHost{ { Domains: []string{"d.test"}, Metadata: proxyVirtualHostMetadata{ Source: []proxyVirtualHostMetadataSource{ { Kind: "*v1.Unknown", Name: "my-unknown-svc", Namespace: "unknown", }, }, }, }, { Domains: []string{"e.test"}, Metadata: proxyVirtualHostMetadata{ Source: []proxyVirtualHostMetadataSource{ { Kind: "*v1.VirtualService", Name: "my-external-svc", Namespace: "external", }, }, }, }, }, }, }, }, }, } var externalProxySvc = corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: externalProxy.Metadata.Name, Namespace: externalProxy.Metadata.Namespace, }, Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeLoadBalancer, }, Status: corev1.ServiceStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { Hostname: "a.example.org", }, { Hostname: "b.example.org", }, { Hostname: "c.example.org", }, }, }, }, } var externalProxySource = metav1.PartialObjectMetadata{ TypeMeta: metav1.TypeMeta{ APIVersion: virtualServiceGVR.GroupVersion().String(), Kind: "VirtualService", }, ObjectMeta: metav1.ObjectMeta{ Name: externalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Name, Namespace: externalProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Namespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "24", "external-dns.alpha.kubernetes.io/aws-geolocation-country-code": "JP", "external-dns.alpha.kubernetes.io/set-identifier": "identifier-external", }, }, } // Proxy with metadata static test var proxyWithMetadataStatic = proxy{ TypeMeta: metav1.TypeMeta{ APIVersion: proxyGVR.GroupVersion().String(), Kind: "Proxy", }, Metadata: metav1.ObjectMeta{ Name: "internal-static", Namespace: defaultGlooNamespace, }, Spec: proxySpec{ Listeners: []proxySpecListener{ { HTTPListener: proxySpecHTTPListener{ VirtualHosts: []proxyVirtualHost{ { Domains: []string{"f.test", "g.test"}, MetadataStatic: proxyVirtualHostMetadataStatic{ Source: []proxyVirtualHostMetadataStaticSource{ { ResourceKind: "*v1.Unknown", ResourceRef: proxyVirtualHostMetadataSourceResourceRef{ Name: "my-unknown-svc", Namespace: "unknown", }, }, }, }, }, { Domains: []string{"h.test"}, MetadataStatic: proxyVirtualHostMetadataStatic{ Source: []proxyVirtualHostMetadataStaticSource{ { ResourceKind: "*v1.VirtualService", ResourceRef: proxyVirtualHostMetadataSourceResourceRef{ Name: "my-internal-static-svc", Namespace: "internal-static", }, }, }, }, }, }, }, }, }, }, } var proxyWithMetadataStaticSvc = corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: proxyWithMetadataStatic.Metadata.Name, Namespace: proxyWithMetadataStatic.Metadata.Namespace, }, Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeLoadBalancer, }, Status: corev1.ServiceStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { IP: "203.0.115.1", }, { IP: "203.0.115.2", }, { IP: "203.0.115.3", }, }, }, }, } var proxyWithMetadataStaticSource = metav1.PartialObjectMetadata{ TypeMeta: metav1.TypeMeta{ APIVersion: virtualServiceGVR.GroupVersion().String(), Kind: "VirtualService", }, ObjectMeta: metav1.ObjectMeta{ Name: proxyWithMetadataStatic.Spec.Listeners[0].HTTPListener.VirtualHosts[1].MetadataStatic.Source[0].ResourceRef.Name, Namespace: proxyWithMetadataStatic.Spec.Listeners[0].HTTPListener.VirtualHosts[1].MetadataStatic.Source[0].ResourceRef.Namespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "420", "external-dns.alpha.kubernetes.io/aws-geolocation-country-code": "ES", "external-dns.alpha.kubernetes.io/set-identifier": "identifier", }, }, } // Proxy with target annotation test var targetAnnotatedProxy = proxy{ TypeMeta: metav1.TypeMeta{ APIVersion: proxyGVR.GroupVersion().String(), Kind: "Proxy", }, Metadata: metav1.ObjectMeta{ Name: "target-ann", Namespace: defaultGlooNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "203.2.45.7", }, }, Spec: proxySpec{ Listeners: []proxySpecListener{ { HTTPListener: proxySpecHTTPListener{ VirtualHosts: []proxyVirtualHost{ { Domains: []string{"i.test"}, Metadata: proxyVirtualHostMetadata{ Source: []proxyVirtualHostMetadataSource{ { Kind: "*v1.Unknown", Name: "my-unknown-svc", Namespace: "unknown", }, }, }, }, { Domains: []string{"j.test"}, Metadata: proxyVirtualHostMetadata{ Source: []proxyVirtualHostMetadataSource{ { Kind: "*v1.VirtualService", Name: "my-annotated-svc", Namespace: "internal", }, }, }, }, }, }, }, }, }, } var targetAnnotatedProxySvc = corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: targetAnnotatedProxy.Metadata.Name, Namespace: targetAnnotatedProxy.Metadata.Namespace, }, Spec: corev1.ServiceSpec{ Type: corev1.ServiceTypeLoadBalancer, }, Status: corev1.ServiceStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { IP: "203.1.115.1", }, { IP: "203.1.115.2", }, { IP: "203.1.115.3", }, }, }, }, } var targetAnnotatedProxySource = metav1.PartialObjectMetadata{ TypeMeta: metav1.TypeMeta{ APIVersion: virtualServiceGVR.GroupVersion().String(), Kind: "VirtualService", }, ObjectMeta: metav1.ObjectMeta{ Name: targetAnnotatedProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Name, Namespace: targetAnnotatedProxy.Spec.Listeners[0].HTTPListener.VirtualHosts[1].Metadata.Source[0].Namespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ttl": "460", "external-dns.alpha.kubernetes.io/aws-geolocation-country-code": "IT", "external-dns.alpha.kubernetes.io/set-identifier": "identifier-annotated", }, }, } // Proxy backed by Ingress var gatewayIngressAnnotatedProxy = proxy{ TypeMeta: metav1.TypeMeta{ APIVersion: proxyGVR.GroupVersion().String(), Kind: "Proxy", }, Metadata: metav1.ObjectMeta{ Name: "gateway-ingress-annotated", Namespace: defaultGlooNamespace, }, Spec: proxySpec{ Listeners: []proxySpecListener{ { HTTPListener: proxySpecHTTPListener{ VirtualHosts: []proxyVirtualHost{ { Domains: []string{"k.test"}, MetadataStatic: proxyVirtualHostMetadataStatic{ Source: []proxyVirtualHostMetadataStaticSource{ { ResourceKind: "*v1.Unknown", ResourceRef: proxyVirtualHostMetadataSourceResourceRef{ Name: "my-unknown-svc", Namespace: "unknown", }, }, }, }, }, }, }, MetadataStatic: proxyMetadataStatic{ Source: []proxyMetadataStaticSource{ { ResourceKind: "*v1.Gateway", ResourceRef: proxyMetadataStaticSourceResourceRef{ Name: "gateway-ingress-annotated", Namespace: defaultGlooNamespace, }, }, }, }, }, }, }, } var gatewayIngressAnnotatedProxyGateway = metav1.PartialObjectMetadata{ TypeMeta: metav1.TypeMeta{ APIVersion: gatewayGVR.GroupVersion().String(), Kind: "Gateway", }, ObjectMeta: metav1.ObjectMeta{ Name: gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Name, Namespace: gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Namespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/ingress": fmt.Sprintf("%s/%s", gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Namespace, gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Name), }, }, } var gatewayIngressAnnotatedProxyIngress = networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Name, Namespace: gatewayIngressAnnotatedProxy.Spec.Listeners[0].MetadataStatic.Source[0].ResourceRef.Namespace, }, Status: networkingv1.IngressStatus{ LoadBalancer: networkingv1.IngressLoadBalancerStatus{ Ingress: []networkingv1.IngressLoadBalancerIngress{ { Hostname: "example.com", }, }, }, }, } func TestGlooSource(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() fakeDynamicClient := fakeDynamic.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ proxyGVR: "ProxyList", virtualServiceGVR: "VirtualServiceList", gatewayGVR: "GatewayList", }) internalProxyUnstructured := unstructured.Unstructured{} externalProxyUnstructured := unstructured.Unstructured{} gatewayIngressAnnotatedProxyUnstructured := unstructured.Unstructured{} gatewayIngressAnnotatedProxyGatewayUnstructured := unstructured.Unstructured{} proxyMetadataStaticUnstructured := unstructured.Unstructured{} targetAnnotatedProxyUnstructured := unstructured.Unstructured{} internalProxySourceUnstructured := unstructured.Unstructured{} externalProxySourceUnstructured := unstructured.Unstructured{} proxyMetadataStaticSourceUnstructured := unstructured.Unstructured{} targetAnnotatedProxySourceUnstructured := unstructured.Unstructured{} internalProxyAsJSON, err := json.Marshal(internalProxy) assert.NoError(t, err) externalProxyAsJSON, err := json.Marshal(externalProxy) assert.NoError(t, err) gatewayIngressAnnotatedProxyAsJSON, err := json.Marshal(gatewayIngressAnnotatedProxy) assert.NoError(t, err) gatewayIngressAnnotatedProxyGatewayAsJSON, err := json.Marshal(gatewayIngressAnnotatedProxyGateway) assert.NoError(t, err) proxyMetadataStaticAsJSON, err := json.Marshal(proxyWithMetadataStatic) assert.NoError(t, err) targetAnnotatedProxyAsJSON, err := json.Marshal(targetAnnotatedProxy) assert.NoError(t, err) internalProxySvcAsJSON, err := json.Marshal(internalProxySource) assert.NoError(t, err) externalProxySvcAsJSON, err := json.Marshal(externalProxySource) assert.NoError(t, err) proxyMetadataStaticSvcAsJSON, err := json.Marshal(proxyWithMetadataStaticSource) assert.NoError(t, err) targetAnnotatedProxySvcAsJSON, err := json.Marshal(targetAnnotatedProxySource) assert.NoError(t, err) assert.NoError(t, internalProxyUnstructured.UnmarshalJSON(internalProxyAsJSON)) assert.NoError(t, externalProxyUnstructured.UnmarshalJSON(externalProxyAsJSON)) assert.NoError(t, gatewayIngressAnnotatedProxyUnstructured.UnmarshalJSON(gatewayIngressAnnotatedProxyAsJSON)) assert.NoError(t, gatewayIngressAnnotatedProxyGatewayUnstructured.UnmarshalJSON(gatewayIngressAnnotatedProxyGatewayAsJSON)) assert.NoError(t, proxyMetadataStaticUnstructured.UnmarshalJSON(proxyMetadataStaticAsJSON)) assert.NoError(t, targetAnnotatedProxyUnstructured.UnmarshalJSON(targetAnnotatedProxyAsJSON)) assert.NoError(t, internalProxySourceUnstructured.UnmarshalJSON(internalProxySvcAsJSON)) assert.NoError(t, externalProxySourceUnstructured.UnmarshalJSON(externalProxySvcAsJSON)) assert.NoError(t, proxyMetadataStaticSourceUnstructured.UnmarshalJSON(proxyMetadataStaticSvcAsJSON)) assert.NoError(t, targetAnnotatedProxySourceUnstructured.UnmarshalJSON(targetAnnotatedProxySvcAsJSON)) _, err = fakeKubernetesClient.CoreV1().Services(internalProxySvc.GetNamespace()).Create(t.Context(), &internalProxySvc, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeKubernetesClient.CoreV1().Services(externalProxySvc.GetNamespace()).Create(t.Context(), &externalProxySvc, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeKubernetesClient.CoreV1().Services(proxyWithMetadataStaticSvc.GetNamespace()).Create(t.Context(), &proxyWithMetadataStaticSvc, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeKubernetesClient.CoreV1().Services(targetAnnotatedProxySvc.GetNamespace()).Create(t.Context(), &targetAnnotatedProxySvc, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeKubernetesClient.NetworkingV1().Ingresses(gatewayIngressAnnotatedProxyIngress.GetNamespace()).Create(t.Context(), &gatewayIngressAnnotatedProxyIngress, metav1.CreateOptions{}) assert.NoError(t, err) // Create proxy resources _, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &internalProxyUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &externalProxyUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &proxyMetadataStaticUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &targetAnnotatedProxyUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeDynamicClient.Resource(proxyGVR).Namespace(defaultGlooNamespace).Create(t.Context(), &gatewayIngressAnnotatedProxyUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) // Create proxy source _, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(internalProxySource.Namespace).Create(t.Context(), &internalProxySourceUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(externalProxySource.Namespace).Create(t.Context(), &externalProxySourceUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(proxyWithMetadataStaticSource.Namespace).Create(t.Context(), &proxyMetadataStaticSourceUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) _, err = fakeDynamicClient.Resource(virtualServiceGVR).Namespace(targetAnnotatedProxySource.Namespace).Create(t.Context(), &targetAnnotatedProxySourceUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) // Create gateway resource _, err = fakeDynamicClient.Resource(gatewayGVR).Namespace(gatewayIngressAnnotatedProxyGateway.Namespace).Create(t.Context(), &gatewayIngressAnnotatedProxyGatewayUnstructured, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewGlooSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ GlooNamespaces: []string{defaultGlooNamespace}, }) assert.NoError(t, err) assert.NotNil(t, source) endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) assert.Len(t, endpoints, 11) assert.ElementsMatch(t, endpoints, []*endpoint.Endpoint{ { DNSName: "a.test", Targets: []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "b.test", Targets: []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "c.test", Targets: []string{internalProxySvc.Status.LoadBalancer.Ingress[0].IP, internalProxySvc.Status.LoadBalancer.Ingress[1].IP, internalProxySvc.Status.LoadBalancer.Ingress[2].IP}, RecordType: endpoint.RecordTypeA, SetIdentifier: "identifier", RecordTTL: 42, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "aws/geolocation-country-code", Value: "LU", }, }, }, { DNSName: "d.test", Targets: []string{externalProxySvc.Status.LoadBalancer.Ingress[0].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[1].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[2].Hostname}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "e.test", Targets: []string{externalProxySvc.Status.LoadBalancer.Ingress[0].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[1].Hostname, externalProxySvc.Status.LoadBalancer.Ingress[2].Hostname}, RecordType: endpoint.RecordTypeCNAME, SetIdentifier: "identifier-external", RecordTTL: 24, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "aws/geolocation-country-code", Value: "JP", }, }, }, { DNSName: "f.test", Targets: []string{proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[0].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[1].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[2].IP}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "g.test", Targets: []string{proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[0].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[1].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[2].IP}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.test", Targets: []string{proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[0].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[1].IP, proxyWithMetadataStaticSvc.Status.LoadBalancer.Ingress[2].IP}, RecordType: endpoint.RecordTypeA, SetIdentifier: "identifier", RecordTTL: 420, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "aws/geolocation-country-code", Value: "ES", }, }, }, { DNSName: "i.test", Targets: []string{"203.2.45.7"}, RecordType: endpoint.RecordTypeA, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "j.test", Targets: []string{"203.2.45.7"}, RecordType: endpoint.RecordTypeA, SetIdentifier: "identifier-annotated", RecordTTL: 460, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{ endpoint.ProviderSpecificProperty{ Name: "aws/geolocation-country-code", Value: "IT", }, }, }, { DNSName: "k.test", Targets: []string{gatewayIngressAnnotatedProxyIngress.Status.LoadBalancer.Ingress[0].Hostname}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{}, ProviderSpecific: endpoint.ProviderSpecific{}}, }) } ================================================ FILE: source/informers/fake.go ================================================ /* 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 informers import ( "github.com/stretchr/testify/mock" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" corev1lister "k8s.io/client-go/listers/core/v1" discoveryv1lister "k8s.io/client-go/listers/discovery/v1" "k8s.io/client-go/tools/cache" ) type FakeServiceInformer struct { mock.Mock } func (f *FakeServiceInformer) Informer() cache.SharedIndexInformer { args := f.Called() return args.Get(0).(cache.SharedIndexInformer) } func (f *FakeServiceInformer) Lister() corev1lister.ServiceLister { return corev1lister.NewServiceLister(f.Informer().GetIndexer()) } type FakeEndpointSliceInformer struct { mock.Mock } func (f *FakeEndpointSliceInformer) Informer() cache.SharedIndexInformer { args := f.Called() return args.Get(0).(cache.SharedIndexInformer) } func (f *FakeEndpointSliceInformer) Lister() discoveryv1lister.EndpointSliceLister { return discoveryv1lister.NewEndpointSliceLister(f.Informer().GetIndexer()) } type FakeNodeInformer struct { mock.Mock } func (f *FakeNodeInformer) Informer() cache.SharedIndexInformer { args := f.Called() return args.Get(0).(cache.SharedIndexInformer) } func (f *FakeNodeInformer) Lister() corev1lister.NodeLister { return corev1lister.NewNodeLister(f.Informer().GetIndexer()) } func fakeService() *corev1.Service { return &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "fake-service", Namespace: "ns", Labels: map[string]string{"env": "prod", "team": "devops"}, Annotations: map[string]string{"description": "Enriched service object"}, UID: "1234", }, Spec: corev1.ServiceSpec{ Selector: map[string]string{"app": "demo"}, ExternalIPs: []string{"1.2.3.4"}, Ports: []corev1.ServicePort{ { Name: "http", Port: 80, TargetPort: intstr.FromInt32(8080), Protocol: corev1.ProtocolTCP, }, { Name: "https", Port: 443, TargetPort: intstr.FromInt32(8443), Protocol: corev1.ProtocolTCP, }, }, Type: corev1.ServiceTypeLoadBalancer, }, Status: corev1.ServiceStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ {IP: "5.6.7.8", Hostname: "lb.example.com"}, }, }, Conditions: []metav1.Condition{ { Type: "Available", Status: metav1.ConditionTrue, Reason: "MinimumReplicasAvailable", Message: "Service is available", LastTransitionTime: metav1.Now(), }, }, }, } } ================================================ FILE: source/informers/handlers.go ================================================ /* 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 informers import ( log "github.com/sirupsen/logrus" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/client-go/tools/cache" ) func DefaultEventHandler(handlers ...func()) cache.ResourceEventHandler { return cache.ResourceEventHandlerFuncs{ AddFunc: func(obj any) { if u, ok := obj.(*unstructured.Unstructured); ok { log.WithFields(log.Fields{ "apiVersion": u.GetAPIVersion(), "kind": u.GetKind(), "namespace": u.GetNamespace(), "name": u.GetName(), }).Debug("added") for _, handler := range handlers { handler() } } }, } } ================================================ FILE: source/informers/handlers_test.go ================================================ /* 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 informers import ( "testing" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) func TestDefaultEventHandler_AddFunc(t *testing.T) { tests := []struct { name string obj any expected bool }{ { name: "calls handler for unstructured object", obj: &unstructured.Unstructured{}, expected: true, }, { name: "does not call handler for unknown object", obj: "not-unstructured", expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { called := false handler := DefaultEventHandler(func() { called = true }) handler.OnAdd(tt.obj, true) if called != tt.expected { t.Errorf("handler called = %v, want %v", called, tt.expected) } }) } } ================================================ FILE: source/informers/indexers.go ================================================ /* 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 informers import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/source/annotations" ) const ( IndexWithSelectors = "withSelectors" ) type IndexSelectorOptions struct { annotationFilter labels.Selector labelSelector labels.Selector } func IndexSelectorWithAnnotationFilter(input string) func(options *IndexSelectorOptions) { return func(options *IndexSelectorOptions) { if input == "" { return } selector, err := annotations.ParseFilter(input) if err != nil { return } options.annotationFilter = selector } } func IndexSelectorWithLabelSelector(input labels.Selector) func(options *IndexSelectorOptions) { return func(options *IndexSelectorOptions) { options.labelSelector = input } } // IndexerWithOptions is a generic function that allows adding multiple indexers // to a SharedIndexInformer for a specific Kubernetes resource type T. It accepts // a variadic list of indexer functions, which define custom indexing logic. // // Each indexer function is applied to objects of type T, enabling flexible and // reusable indexing based on annotations, labels, or other criteria. // // Example usage: // err := IndexerWithOptions[*v1.Pod]( // // IndexSelectorWithAnnotationFilter("example-annotation"), // IndexSelectorWithLabelSelector(labels.SelectorFromSet(labels.Set{"app": "my-app"})), // // ) // // This function ensures type safety and simplifies the process of adding // custom indexers to informers. func IndexerWithOptions[T metav1.Object](optFns ...func(options *IndexSelectorOptions)) cache.Indexers { options := IndexSelectorOptions{} for _, fn := range optFns { fn(&options) } return cache.Indexers{ IndexWithSelectors: func(obj any) ([]string, error) { entity, ok := obj.(T) if !ok { return nil, fmt.Errorf("object is not of type %T", new(T)) } if options.annotationFilter != nil && !options.annotationFilter.Matches(labels.Set(entity.GetAnnotations())) { return nil, nil } if options.labelSelector != nil && !options.labelSelector.Matches(labels.Set(entity.GetLabels())) { return nil, nil } key := types.NamespacedName{Namespace: entity.GetNamespace(), Name: entity.GetName()}.String() return []string{key}, nil }, } } // GetByKey retrieves an object of type T (metav1.Object) from the given cache.Indexer by its key. // It returns the object and an error if the retrieval or type assertion fails. // If the object does not exist, it returns the zero value of T and nil. func GetByKey[T metav1.Object](indexer cache.Indexer, key string) (T, error) { var entity T obj, exists, err := indexer.GetByKey(key) if err != nil || !exists { return entity, err } entity, ok := obj.(T) if !ok { return entity, fmt.Errorf("object is not of type %T", new(T)) } return entity, nil } ================================================ FILE: source/informers/indexers_test.go ================================================ /* 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 informers import ( "testing" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/source/annotations" ) func TestIndexerWithOptions_FilterByAnnotation(t *testing.T) { indexers := IndexerWithOptions[*unstructured.Unstructured]( IndexSelectorWithAnnotationFilter("example-annotation"), ) obj := &unstructured.Unstructured{} obj.SetAnnotations(map[string]string{"example-annotation": "value"}) obj.SetNamespace("default") obj.SetName("test-object") keys, err := indexers[IndexWithSelectors](obj) assert.NoError(t, err) assert.Equal(t, []string{"default/test-object"}, keys) } func TestIndexerWithOptions_FilterByLabel(t *testing.T) { labelSelector := labels.SelectorFromSet(labels.Set{"app": "nginx"}) indexers := IndexerWithOptions[*corev1.Pod]( IndexSelectorWithLabelSelector(labelSelector), ) obj := &corev1.Pod{} obj.SetLabels(map[string]string{"app": "nginx"}) obj.SetNamespace("default") obj.SetName("test-object") keys, err := indexers[IndexWithSelectors](obj) assert.NoError(t, err) assert.Equal(t, []string{"default/test-object"}, keys) } func TestIndexerWithOptions_NoMatch(t *testing.T) { labelSelector := labels.SelectorFromSet(labels.Set{"app": "nginx"}) indexers := IndexerWithOptions[*unstructured.Unstructured]( IndexSelectorWithLabelSelector(labelSelector), ) obj := &unstructured.Unstructured{} obj.SetLabels(map[string]string{"app": "apache"}) obj.SetNamespace("default") obj.SetName("test-object") keys, err := indexers[IndexWithSelectors](obj) assert.NoError(t, err) assert.Nil(t, keys) } func TestIndexerWithOptions_InvalidType(t *testing.T) { indexers := IndexerWithOptions[*unstructured.Unstructured]() obj := "invalid-object" keys, err := indexers[IndexWithSelectors](obj) assert.Error(t, err) assert.Nil(t, keys) assert.Contains(t, err.Error(), "object is not of type") } func TestIndexerWithOptions_EmptyOptions(t *testing.T) { indexers := IndexerWithOptions[*unstructured.Unstructured]() obj := &unstructured.Unstructured{} obj.SetNamespace("default") obj.SetName("test-object") keys, err := indexers["withSelectors"](obj) assert.NoError(t, err) assert.Equal(t, []string{"default/test-object"}, keys) } func TestIndexerWithOptions_AnnotationFilterNoMatch(t *testing.T) { indexers := IndexerWithOptions[*unstructured.Unstructured]( IndexSelectorWithAnnotationFilter("example-annotation=value"), ) obj := &unstructured.Unstructured{} obj.SetAnnotations(map[string]string{"other-annotation": "value"}) obj.SetNamespace("default") obj.SetName("test-object") keys, err := indexers[IndexWithSelectors](obj) assert.NoError(t, err) assert.Nil(t, keys) } func TestIndexSelectorWithAnnotationFilter(t *testing.T) { tests := []struct { name string input string expectedFilter labels.Selector }{ { name: "valid input", input: "key=value", expectedFilter: func() labels.Selector { s, _ := annotations.ParseFilter("key=value"); return s }(), }, { name: "empty input", input: "", expectedFilter: nil, }, { name: "key only filter", input: "app", expectedFilter: func() labels.Selector { s, _ := annotations.ParseFilter("app"); return s }(), }, { name: "poisoned input", input: "=app", expectedFilter: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { options := &IndexSelectorOptions{} IndexSelectorWithAnnotationFilter(tt.input)(options) assert.Equal(t, tt.expectedFilter, options.annotationFilter) }) } } func TestGetByKey_ObjectExists(t *testing.T) { indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) pod := &corev1.Pod{} pod.SetNamespace("default") pod.SetName("test-pod") err := indexer.Add(pod) assert.NoError(t, err) result, err := GetByKey[*corev1.Pod](indexer, "default/test-pod") assert.NoError(t, err) assert.NotNil(t, result) assert.Equal(t, "test-pod", result.GetName()) } func TestGetByKey_ObjectDoesNotExist(t *testing.T) { indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) result, err := GetByKey[*corev1.Pod](indexer, "default/non-existent-pod") assert.NoError(t, err) assert.Nil(t, result) } func TestGetByKey_TypeAssertionFailure(t *testing.T) { indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) service := &corev1.Service{} service.SetNamespace("default") service.SetName("test-service") err := indexer.Add(service) assert.NoError(t, err) result, err := GetByKey[*corev1.Pod](indexer, "default/test-service") assert.Error(t, err) assert.Contains(t, err.Error(), "object is not of type") assert.Nil(t, result) } ================================================ FILE: source/informers/informers.go ================================================ /* 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 informers import ( "context" "fmt" "reflect" "time" "k8s.io/apimachinery/pkg/runtime/schema" ) const ( defaultRequestTimeout = 60 ) type informerFactory interface { WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool } type dynamicInformerFactory interface { WaitForCacheSync(stopCh <-chan struct{}) map[schema.GroupVersionResource]bool } func WaitForCacheSync(ctx context.Context, factory informerFactory) error { return waitForCacheSync(ctx, factory.WaitForCacheSync) } func WaitForDynamicCacheSync(ctx context.Context, factory dynamicInformerFactory) error { return waitForCacheSync(ctx, factory.WaitForCacheSync) } // waitForCacheSync waits for informer caches to sync with a default timeout. // Returns an error if any cache fails to sync, wrapping the context error if a timeout occurred. func waitForCacheSync[K comparable](ctx context.Context, waitFunc func(<-chan struct{}) map[K]bool) error { // The function receives a ctx but then creates a new timeout, // effectively overriding whatever deadline the caller may have set. // If the caller passed a context with a 30s timeout, this function ignores it and waits 60s anyway. timeout := defaultRequestTimeout * time.Second ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() for typ, done := range waitFunc(ctx.Done()) { if !done { if ctx.Err() != nil { return fmt.Errorf("failed to sync %v after %s: %w", typ, timeout, ctx.Err()) } return fmt.Errorf("failed to sync %v", typ) } } return nil } ================================================ FILE: source/informers/informers_test.go ================================================ /* 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 informers import ( "reflect" "testing" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/runtime/schema" ) type mockInformerFactory struct { syncResults map[reflect.Type]bool } func (m *mockInformerFactory) WaitForCacheSync(_ <-chan struct{}) map[reflect.Type]bool { return m.syncResults } type mockDynamicInformerFactory struct { syncResults map[schema.GroupVersionResource]bool } func (m *mockDynamicInformerFactory) WaitForCacheSync(_ <-chan struct{}) map[schema.GroupVersionResource]bool { return m.syncResults } func TestWaitForCacheSync(t *testing.T) { tests := []struct { name string syncResults map[reflect.Type]bool expectError bool errorMsg string }{ { name: "all caches synced", syncResults: map[reflect.Type]bool{reflect.TypeFor[string](): true}, }, { name: "some caches not synced", syncResults: map[reflect.Type]bool{reflect.TypeFor[string](): false}, expectError: true, errorMsg: "failed to sync string with timeout 1m0s", }, { name: "context timeout", syncResults: map[reflect.Type]bool{reflect.TypeFor[string](): false}, expectError: true, errorMsg: "failed to sync string with timeout 1m0s", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := t.Context() factory := &mockInformerFactory{syncResults: tt.syncResults} err := WaitForCacheSync(ctx, factory) if tt.expectError { assert.Error(t, err) assert.Errorf(t, err, tt.errorMsg) } else { assert.NoError(t, err) } }) } } func TestWaitForDynamicCacheSync(t *testing.T) { tests := []struct { name string syncResults map[schema.GroupVersionResource]bool expectError bool errorMsg string }{ { name: "all caches synced", syncResults: map[schema.GroupVersionResource]bool{{}: true}, }, { name: "some caches not synced", syncResults: map[schema.GroupVersionResource]bool{{}: false}, expectError: true, errorMsg: "failed to sync string with timeout 1m0s", }, { name: "context timeout", syncResults: map[schema.GroupVersionResource]bool{{}: false}, expectError: true, errorMsg: "failed to sync string with timeout 1m0s", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := t.Context() factory := &mockDynamicInformerFactory{syncResults: tt.syncResults} err := WaitForDynamicCacheSync(ctx, factory) if tt.expectError { assert.Error(t, err) assert.Errorf(t, err, tt.errorMsg) } else { assert.NoError(t, err) } }) } } ================================================ FILE: source/informers/transfomers.go ================================================ /* 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 informers import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/cache" ) type TransformOptions struct { specSelector bool specExternalIps bool statusLb bool } func TransformerWithOptions[T metav1.Object](optFns ...func(options *TransformOptions)) cache.TransformFunc { options := TransformOptions{} for _, fn := range optFns { fn(&options) } return func(obj any) (any, error) { // only transform if the object is a Service at the moment entity, ok := obj.(*corev1.Service) if !ok { return nil, nil } if entity.UID == "" { // Pod was already transformed and we must be idempotent. return entity, nil } svc := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: entity.Name, Namespace: entity.Namespace, DeletionTimestamp: entity.DeletionTimestamp, }, Spec: corev1.ServiceSpec{}, Status: corev1.ServiceStatus{}, } if options.specSelector { svc.Spec.Selector = entity.Spec.Selector } if options.specExternalIps { svc.Spec.ExternalIPs = entity.Spec.ExternalIPs } if options.statusLb { svc.Status.LoadBalancer = entity.Status.LoadBalancer } return svc, nil } } // TransformWithSpecSelector enables copying the Service's .spec.selector field. func TransformWithSpecSelector() func(options *TransformOptions) { return func(options *TransformOptions) { options.specSelector = true } } // TransformWithSpecExternalIPs enables copying the Service's .spec.externalIPs field. func TransformWithSpecExternalIPs() func(options *TransformOptions) { return func(options *TransformOptions) { options.specExternalIps = true } } // TransformWithStatusLoadBalancer enables copying the Service's .status.loadBalancer field. func TransformWithStatusLoadBalancer() func(options *TransformOptions) { return func(options *TransformOptions) { options.statusLb = true } } ================================================ FILE: source/informers/transformers_test.go ================================================ /* 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 informers import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" ) func TestTransformerWithOptions_Service(t *testing.T) { base := fakeService() tests := []struct { name string options []func(*TransformOptions) asserts func(any) }{ { name: "minimalistic object", options: nil, asserts: func(obj any) { svc, ok := obj.(*corev1.Service) assert.True(t, ok) assert.Empty(t, svc.UID) assert.NotEmpty(t, svc.Name) assert.NotEmpty(t, svc.Namespace) }, }, { name: "with selector", options: []func(*TransformOptions){TransformWithSpecSelector()}, asserts: func(obj any) { svc, ok := obj.(*corev1.Service) assert.True(t, ok) assert.NotEmpty(t, svc.Spec.Selector) assert.Empty(t, svc.Spec.ExternalIPs) assert.Empty(t, svc.Status.LoadBalancer.Ingress) }, }, { name: "with selector", options: []func(*TransformOptions){TransformWithSpecSelector()}, asserts: func(obj any) { svc, ok := obj.(*corev1.Service) assert.True(t, ok) assert.NotEmpty(t, svc.Spec.Selector) assert.Empty(t, svc.Spec.ExternalIPs) assert.Empty(t, svc.Status.LoadBalancer.Ingress) }, }, { name: "with loadBalancer", options: []func(*TransformOptions){TransformWithStatusLoadBalancer()}, asserts: func(obj any) { svc, ok := obj.(*corev1.Service) assert.True(t, ok) assert.Empty(t, svc.Spec.Selector) assert.Empty(t, svc.Spec.ExternalIPs) assert.NotEmpty(t, svc.Status.LoadBalancer.Ingress) }, }, { name: "all options", options: []func(*TransformOptions){ TransformWithSpecSelector(), TransformWithSpecExternalIPs(), TransformWithStatusLoadBalancer(), }, asserts: func(obj any) { svc, ok := obj.(*corev1.Service) assert.True(t, ok) assert.NotEmpty(t, svc.Spec.Selector) assert.NotEmpty(t, svc.Spec.ExternalIPs) assert.NotEmpty(t, svc.Status.LoadBalancer.Ingress) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { transform := TransformerWithOptions[*corev1.Service](tt.options...) got, err := transform(base) require.NoError(t, err) tt.asserts(got) }) } t.Run("non-service input", func(t *testing.T) { transform := TransformerWithOptions[*corev1.Service]() out, err := transform("not-a-service") if err != nil { t.Fatalf("unexpected error: %v", err) } if out != nil { t.Errorf("expected nil output for non-service input, got %v", out) } }) } func TestTransformer_Service_WithFakeClient(t *testing.T) { t.Run("with transformer", func(t *testing.T) { ctx := t.Context() svc := fakeService() fakeClient := fake.NewClientset() _, err := fakeClient.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{}) require.NoError(t, err) factory := kubeinformers.NewSharedInformerFactoryWithOptions(fakeClient, 0, kubeinformers.WithNamespace(svc.Namespace)) serviceInformer := factory.Core().V1().Services() err = serviceInformer.Informer().SetTransform(TransformerWithOptions[*corev1.Service]( TransformWithSpecSelector(), TransformWithSpecExternalIPs(), TransformWithStatusLoadBalancer(), )) require.NoError(t, err) factory.Start(ctx.Done()) err = WaitForCacheSync(ctx, factory) require.NoError(t, err) got, err := serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name) require.NoError(t, err) assert.Equal(t, svc.Spec.Selector, got.Spec.Selector) assert.Equal(t, svc.Spec.ExternalIPs, got.Spec.ExternalIPs) assert.Equal(t, svc.Status.LoadBalancer.Ingress, got.Status.LoadBalancer.Ingress) assert.NotEqual(t, svc.Annotations, got.Annotations) assert.NotEqual(t, svc.Labels, got.Labels) }) t.Run("without transformer", func(t *testing.T) { ctx := t.Context() svc := fakeService() fakeClient := fake.NewClientset() _, err := fakeClient.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{}) require.NoError(t, err) factory := kubeinformers.NewSharedInformerFactoryWithOptions(fakeClient, 0, kubeinformers.WithNamespace(svc.Namespace)) serviceInformer := factory.Core().V1().Services() err = serviceInformer.Informer().GetIndexer().Add(svc) require.NoError(t, err) factory.Start(ctx.Done()) err = WaitForCacheSync(ctx, factory) require.NoError(t, err) got, err := serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name) require.NoError(t, err) assert.Equal(t, map[string]string{"app": "demo"}, got.Spec.Selector) assert.Equal(t, []string{"1.2.3.4"}, got.Spec.ExternalIPs) assert.Equal(t, svc.Status.LoadBalancer.Ingress, got.Status.LoadBalancer.Ingress) assert.Equal(t, svc.Annotations, got.Annotations) assert.Equal(t, svc.Labels, got.Labels) }) } ================================================ FILE: source/ingress.go ================================================ /* Copyright 2017 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 source import ( "context" "errors" "fmt" "strings" "text/template" log "github.com/sirupsen/logrus" networkv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/selection" kubeinformers "k8s.io/client-go/informers" netinformers "k8s.io/client-go/informers/networking/v1" "k8s.io/client-go/kubernetes" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/source/informers" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" ) const ( // Possible values for the ingress-hostname-source annotation IngressHostnameSourceAnnotationOnlyValue = "annotation-only" IngressHostnameSourceDefinedHostsOnlyValue = "defined-hosts-only" IngressClassAnnotationKey = "kubernetes.io/ingress.class" ) // ingressSource is an implementation of Source for Kubernetes ingress objects. // Ingress implementation will use the spec.rules.host value for the hostname // Use annotations.TargetKey to explicitly set Endpoint. (useful if the ingress // controller does not update, or to override with alternative endpoint) // // +externaldns:source:name=ingress // +externaldns:source:category=Kubernetes Core // +externaldns:source:description=Creates DNS entries based on Kubernetes Ingress resources // +externaldns:source:resources=Ingress // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true // +externaldns:source:events=true type ingressSource struct { client kubernetes.Interface namespace string annotationFilter string ingressClassNames []string fqdnTemplate *template.Template combineFQDNAnnotation bool ignoreHostnameAnnotation bool ingressInformer netinformers.IngressInformer ignoreIngressTLSSpec bool ignoreIngressRulesSpec bool labelSelector labels.Selector } // NewIngressSource creates a new ingressSource with the given config. func NewIngressSource( ctx context.Context, kubeClient kubernetes.Interface, cfg *Config) (Source, error) { tmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } // ensure that ingress class is only set in either the ingressClassNames or // annotationFilter but not both if cfg.IngressClassNames != nil && cfg.AnnotationFilter != "" { selector, err := getLabelSelector(cfg.AnnotationFilter) if err != nil { return nil, err } requirements, _ := selector.Requirements() for _, requirement := range requirements { if requirement.Key() == IngressClassAnnotationKey { return nil, errors.New("--ingress-class is mutually exclusive with the kubernetes.io/ingress.class annotation filter") } } } // Use shared informer to listen for add/update/delete of ingresses in the specified namespace. // Set resync period to 0, to prevent processing when nothing has changed. informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(cfg.Namespace)) ingressInformer := informerFactory.Networking().V1().Ingresses() // Add default resource event handlers to properly initialize informer. _, _ = ingressInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } sc := &ingressSource{ client: kubeClient, namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, ingressClassNames: cfg.IngressClassNames, fqdnTemplate: tmpl, combineFQDNAnnotation: cfg.CombineFQDNAndAnnotation, ignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, ingressInformer: ingressInformer, ignoreIngressTLSSpec: cfg.IgnoreIngressTLSSpec, ignoreIngressRulesSpec: cfg.IgnoreIngressRulesSpec, labelSelector: cfg.LabelFilter, } return sc, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all ingress resources on all namespaces func (sc *ingressSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { ingresses, err := sc.ingressInformer.Lister().Ingresses(sc.namespace).List(sc.labelSelector) if err != nil { return nil, err } ingresses, err = annotations.Filter(ingresses, sc.annotationFilter) if err != nil { return nil, err } ingresses, err = sc.filterByIngressClass(ingresses) if err != nil { return nil, err } endpoints := []*endpoint.Endpoint{} for _, ing := range ingresses { if annotations.IsControllerMismatch(ing, types.Ingress) { continue } ingEndpoints := endpointsFromIngress(ing, sc.ignoreHostnameAnnotation, sc.ignoreIngressTLSSpec, sc.ignoreIngressRulesSpec) // apply template if host is missing on ingress ingEndpoints, err = fqdn.CombineWithTemplatedEndpoints( ingEndpoints, sc.fqdnTemplate, sc.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(ing) }, ) if err != nil { return nil, err } if endpoint.HasNoEmptyEndpoints(ingEndpoints, types.Ingress, ing) { continue } endpoint.AttachRefObject(ingEndpoints, events.NewObjectReference(ing, types.Ingress)) log.Debugf("Endpoints generated from ingress: %s/%s: %v", ing.Namespace, ing.Name, ingEndpoints) endpoints = append(endpoints, ingEndpoints...) } return MergeEndpoints(endpoints), nil } func (sc *ingressSource) endpointsFromTemplate(ing *networkv1.Ingress) ([]*endpoint.Endpoint, error) { hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, ing) if err != nil { return nil, err } resource := fmt.Sprintf("ingress/%s/%s", ing.Namespace, ing.Name) ttl := annotations.TTLFromAnnotations(ing.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(ing.Annotations) if len(targets) == 0 { targets = targetsFromIngressStatus(ing.Status) } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ing.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } // filterByIngressClass filters a list of ingresses based on a required ingress // class func (sc *ingressSource) filterByIngressClass(ingresses []*networkv1.Ingress) ([]*networkv1.Ingress, error) { // if no class filter is specified then there's nothing to do if len(sc.ingressClassNames) == 0 { return ingresses, nil } classNameReq, err := labels.NewRequirement(IngressClassAnnotationKey, selection.In, sc.ingressClassNames) if err != nil { return nil, err } selector := labels.NewSelector() selector = selector.Add(*classNameReq) filteredList := []*networkv1.Ingress{} for _, ingress := range ingresses { var matched = false for _, nameFilter := range sc.ingressClassNames { if ingress.Spec.IngressClassName != nil && len(*ingress.Spec.IngressClassName) > 0 { if nameFilter == *ingress.Spec.IngressClassName { matched = true } } else if matchLabelSelector(selector, ingress.Annotations) { matched = true } if matched { filteredList = append(filteredList, ingress) break } } if !matched { log.Debugf("Discarding ingress %s/%s because it does not match required ingress classes %v", ingress.Namespace, ingress.Name, sc.ingressClassNames) } } return filteredList, nil } // endpointsFromIngress extracts the endpoints from ingress object func endpointsFromIngress(ing *networkv1.Ingress, ignoreHostnameAnnotation bool, ignoreIngressTLSSpec bool, ignoreIngressRulesSpec bool) []*endpoint.Endpoint { resource := fmt.Sprintf("ingress/%s/%s", ing.Namespace, ing.Name) ttl := annotations.TTLFromAnnotations(ing.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(ing.Annotations) if len(targets) == 0 { targets = targetsFromIngressStatus(ing.Status) } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ing.Annotations) // Gather endpoints defined on hosts sections of the ingress var definedHostsEndpoints []*endpoint.Endpoint // Skip endpoints if we do not want entries from Rules section if !ignoreIngressRulesSpec { for _, rule := range ing.Spec.Rules { if rule.Host == "" { continue } definedHostsEndpoints = append(definedHostsEndpoints, endpoint.EndpointsForHostname(rule.Host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } // Skip endpoints if we do not want entries from tls spec section if !ignoreIngressTLSSpec { for _, tls := range ing.Spec.TLS { for _, host := range tls.Hosts { if host == "" { continue } definedHostsEndpoints = append(definedHostsEndpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } } // Gather endpoints defined on annotations in the ingress var annotationEndpoints []*endpoint.Endpoint if !ignoreHostnameAnnotation { for _, hostname := range annotations.HostnamesFromAnnotations(ing.Annotations) { annotationEndpoints = append(annotationEndpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } // Determine which hostnames to consider in our final list hostnameSourceAnnotation, hostnameSourceAnnotationExists := ing.Annotations[annotations.IngressHostnameSourceKey] if !hostnameSourceAnnotationExists { return append(definedHostsEndpoints, annotationEndpoints...) } // Include endpoints according to the hostname source annotation in our final list var endpoints []*endpoint.Endpoint if strings.ToLower(hostnameSourceAnnotation) == IngressHostnameSourceDefinedHostsOnlyValue { endpoints = append(endpoints, definedHostsEndpoints...) } if strings.ToLower(hostnameSourceAnnotation) == IngressHostnameSourceAnnotationOnlyValue { endpoints = append(endpoints, annotationEndpoints...) } return endpoints } // targetsFromIngressStatus extracts targets from ingress load balancer status. // Both IP and Hostname can be set simultaneously (Kubernetes API does not enforce // mutual exclusivity), so we collect both when present. func targetsFromIngressStatus(status networkv1.IngressStatus) endpoint.Targets { var targets endpoint.Targets for _, lb := range status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } return targets } func (sc *ingressSource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for ingress") // Right now there is no way to remove event handler from informer, see: // https://github.com/kubernetes/kubernetes/issues/79610 _, _ = sc.ingressInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } ================================================ FILE: source/ingress_fqdn_test.go ================================================ /* 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 source import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/internal/testutils" networkv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" ) func TestIngressSourceNewNodeSourceWithFqdn(t *testing.T) { for _, tt := range []struct { title string annotationFilter string fqdnTemplate string expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "complex template", expectError: false, fqdnTemplate: "{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.ext-dns.test.com", }, { title: "valid template with multiple hosts", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", }, } { t.Run(tt.title, func(t *testing.T) { _, err := NewIngressSource( t.Context(), fake.NewClientset(), &Config{ FQDNTemplate: tt.fqdnTemplate, LabelFilter: labels.NewSelector(), }, ) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func TestIngressSourceFqdnTemplatingExamples(t *testing.T) { for _, tt := range []struct { title string ingresses []*networkv1.Ingress fqdnTemplate string expected []*endpoint.Endpoint }{ { title: "templating resolve Ingress source hostnames to IP", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("my-ingress"), Rules: []networkv1.IngressRule{ { Host: "example.org", IngressRuleValue: networkv1.IngressRuleValue{ HTTP: &networkv1.HTTPIngressRuleValue{ Paths: []networkv1.HTTPIngressPath{ { Backend: networkv1.IngressBackend{ Service: &networkv1.IngressServiceBackend{ Name: "my-service", Port: networkv1.ServiceBackendPort{ Name: "http", }, }, }, PathType: testutils.ToPtr(networkv1.PathTypePrefix), Path: "/", }, }, }, }, }, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {Hostname: "10.200.130.84.nip.io"}, }, }, }, }, }, fqdnTemplate: "{{.Name }}.nip.io", expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.200.130.84.nip.io"}}, {DNSName: "my-ingress.nip.io", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.200.130.84.nip.io"}}, }, }, { title: "templating resolve hostnames with nip.io", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("my-ingress"), Rules: []networkv1.IngressRule{ {Host: "example.org"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {Hostname: "10.200.130.84.nip.io"}, }, }, }, }, }, fqdnTemplate: `{{ range .Status.LoadBalancer.Ingress }}{{ if contains .Hostname "nip.io" }}example.org{{end}}{{end}}`, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.200.130.84.nip.io"}}, }, }, { title: "templating resolve hostnames with nip.io and target annotation", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "10.200.130.84", }, }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("my-ingress"), Rules: []networkv1.IngressRule{ {Host: "example.org"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {Hostname: "10.200.130.84.nip.io"}, }, }, }, }, }, fqdnTemplate: `{{ range .Status.LoadBalancer.Ingress }}{{ if contains .Hostname "nip.io" }}tld.org{{break}}{{end}}{{end}}`, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.200.130.84"}}, {DNSName: "tld.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.200.130.84"}}, }, }, { title: "templating resolve hostnames with nip.io and status IP", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("my-ingress"), Rules: []networkv1.IngressRule{ { Host: "example.org", }, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ { IP: "10.200.130.84", }, }, }, }, }, }, fqdnTemplate: "nip.io", expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.200.130.84"}}, {DNSName: "nip.io", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.200.130.84"}}, }, }, { title: "templating resolve with different hostnames and rules", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("ingress-with-override"), Rules: []networkv1.IngressRule{ {Host: "foo.bar.com"}, {Host: "bar.bar.com"}, {Host: "bar.baz.com"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {IP: "192.16.15.25"}, {Hostname: "abc.org"}, }, }, }, }, }, fqdnTemplate: `{{ range .Spec.Rules }}{{ if contains .Host "bar.com" }}{{ .Host }}.internal{{break}}{{end}}{{end}}`, expected: []*endpoint.Endpoint{ {DNSName: "foo.bar.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.16.15.25"}}, {DNSName: "foo.bar.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"abc.org"}}, {DNSName: "bar.bar.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.16.15.25"}}, {DNSName: "bar.bar.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"abc.org"}}, {DNSName: "bar.baz.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.16.15.25"}}, {DNSName: "bar.baz.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"abc.org"}}, {DNSName: "foo.bar.com.internal", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.16.15.25"}}, {DNSName: "foo.bar.com.internal", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"abc.org"}}, }, }, { title: "templating resolve with rules and tls", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("ingress-with-override"), Rules: []networkv1.IngressRule{ { Host: "foo.bar.com", }, }, TLS: []networkv1.IngressTLS{ { Hosts: []string{"https-example.foo.com", "https-example.bar.com"}, }, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ { IP: "10.09.15.25", }, }, }, }, }, }, fqdnTemplate: `{{ .Name }}.test.org,{{ range .Spec.TLS }}{{ range $value := .Hosts }}{{ $value | replace "." "-" }}.internal{{break}}{{end}}{{end}}`, expected: []*endpoint.Endpoint{ {DNSName: "foo.bar.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, {DNSName: "https-example.foo.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, {DNSName: "https-example.bar.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, {DNSName: "my-ingress.test.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, {DNSName: "https-example-foo-com.internal", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"10.09.15.25"}}, }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() for _, el := range tt.ingresses { _, err := kubeClient.NetworkingV1().Ingresses(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewIngressSource( t.Context(), kubeClient, &Config{ FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: true, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } ================================================ FILE: source/ingress_test.go ================================================ /* Copyright 2017 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 source import ( "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" networkv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) // Validates that ingressSource is a Source var _ Source = &ingressSource{} type IngressSuite struct { suite.Suite sc Source fooWithTargets *networkv1.Ingress } func (suite *IngressSuite) SetupTest() { fakeClient := fake.NewClientset() suite.fooWithTargets = (fakeIngress{ name: "foo-with-targets", namespace: "default", dnsnames: []string{"foo"}, ips: []string{"8.8.8.8", "2606:4700:4700::1111"}, hostnames: []string{"v1"}, }).Ingress() _, err := fakeClient.NetworkingV1().Ingresses(suite.fooWithTargets.Namespace).Create(context.Background(), suite.fooWithTargets, metav1.CreateOptions{}) suite.NoError(err, "should succeed") suite.sc, err = NewIngressSource( context.TODO(), fakeClient, &Config{ FQDNTemplate: "{{.Name}}", LabelFilter: labels.Everything(), }, ) suite.NoError(err, "should initialize ingress source") } func (suite *IngressSuite) TestResourceLabelIsSet() { endpoints, _ := suite.sc.Endpoints(context.Background()) for _, ep := range endpoints { suite.Equal("ingress/default/foo-with-targets", ep.Labels[endpoint.ResourceLabelKey], "should set correct resource label") } } func TestIngress(t *testing.T) { t.Parallel() suite.Run(t, new(IngressSuite)) t.Run("endpointsFromIngress", testEndpointsFromIngress) t.Run("endpointsFromIngressHostnameSourceAnnotation", testEndpointsFromIngressHostnameSourceAnnotation) t.Run("Endpoints", testIngressEndpoints) } func TestNewIngressSource(t *testing.T) { t.Parallel() for _, ti := range []struct { title string annotationFilter string fqdnTemplate string combineFQDNAndAnnotation bool expectError bool ingressClassNames []string }{ { title: "non-empty annotation filter label", expectError: false, annotationFilter: "kubernetes.io/ingress.class=nginx", }, { title: "non-empty ingress class name list", expectError: false, ingressClassNames: []string{"internal", "external"}, }, { title: "ingress class name and annotation filter jointly specified", expectError: true, ingressClassNames: []string{"internal", "external"}, annotationFilter: "kubernetes.io/ingress.class=nginx", }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() _, err := NewIngressSource( t.Context(), fake.NewClientset(), &Config{ AnnotationFilter: ti.annotationFilter, FQDNTemplate: ti.fqdnTemplate, CombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation, IngressClassNames: ti.ingressClassNames, }, ) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func testEndpointsFromIngress(t *testing.T) { t.Parallel() for _, ti := range []struct { title string ingress fakeIngress ignoreHostnameAnnotation bool ignoreIngressTLSSpec bool ignoreIngressRulesSpec bool expected []*endpoint.Endpoint }{ { title: "one rule.host one lb.hostname", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, // Kubernetes requires removal of trailing dot hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "one rule.host one lb.IP", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, ips: []string{"8.8.8.8"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "one rule.host one lb.IPv6", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, ips: []string{"2606:4700:4700::1111"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2606:4700:4700::1111"}, }, }, }, { title: "one rule.host two lb.IP, two lb.IPv6 and two lb.Hostname", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, ips: []string{"8.8.8.8", "127.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"}, hostnames: []string{"elb.com", "alb.com"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8", "127.0.0.1"}, }, { DNSName: "foo.bar", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2606:4700:4700::1111", "2606:4700:4700::1001"}, }, { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com", "alb.com"}, }, }, }, { title: "no rule.host", ingress: fakeIngress{ ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, }, expected: []*endpoint.Endpoint{}, }, { title: "one empty rule.host", ingress: fakeIngress{ dnsnames: []string{""}, ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, }, expected: []*endpoint.Endpoint{}, }, { title: "no targets", ingress: fakeIngress{ dnsnames: []string{""}, }, expected: []*endpoint.Endpoint{}, }, { title: "ignore rules with one rule.host one lb.hostname", ingress: fakeIngress{ dnsnames: []string{"test"}, // Kubernetes requires removal of trailing dot hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot }, expected: []*endpoint.Endpoint{}, ignoreIngressRulesSpec: true, }, { title: "invalid hostname does not generate endpoints", ingress: fakeIngress{ dnsnames: []string{"this-is-an-exceedingly-long-label-that-external-dns-should-reject.example.org"}, }, expected: []*endpoint.Endpoint{}, }, } { t.Run(ti.title, func(t *testing.T) { realIngress := ti.ingress.Ingress() validateEndpoints(t, endpointsFromIngress(realIngress, ti.ignoreHostnameAnnotation, ti.ignoreIngressTLSSpec, ti.ignoreIngressRulesSpec), ti.expected) }) } } func testEndpointsFromIngressHostnameSourceAnnotation(t *testing.T) { // Host names and host name annotation provided, with various values of the ingress-hostname-source annotation for _, ti := range []struct { title string ingress fakeIngress expected []*endpoint.Endpoint }{ { title: "No ingress-hostname-source annotation, one rule.host, one annotation host", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, annotations: map[string]string{annotations.HostnameKey: "foo.baz"}, hostnames: []string{"lb.com"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "foo.baz", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "No ingress-hostname-source annotation, one rule.host", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, hostnames: []string{"lb.com"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "No ingress-hostname-source annotation, one rule.host, one annotation host", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, annotations: map[string]string{annotations.HostnameKey: "foo.baz"}, hostnames: []string{"lb.com"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "foo.baz", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "Ingress-hostname-source=defined-hosts-only, one rule.host, one annotation host", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, annotations: map[string]string{annotations.HostnameKey: "foo.baz", annotations.IngressHostnameSourceKey: "defined-hosts-only"}, hostnames: []string{"lb.com"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "Ingress-hostname-source=annotation-only, one rule.host, one annotation host", ingress: fakeIngress{ dnsnames: []string{"foo.bar"}, annotations: map[string]string{annotations.HostnameKey: "foo.baz", annotations.IngressHostnameSourceKey: "annotation-only"}, hostnames: []string{"lb.com"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.baz", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, } { t.Run(ti.title, func(t *testing.T) { realIngress := ti.ingress.Ingress() validateEndpoints(t, endpointsFromIngress(realIngress, false, false, false), ti.expected) }) } } func testIngressEndpoints(t *testing.T) { t.Parallel() namespace := "testing" for _, ti := range []struct { title string targetNamespace string annotationFilter string ingressItems []fakeIngress expected []*endpoint.Endpoint expectError bool fqdnTemplate string combineFQDNAndAnnotation bool ignoreHostnameAnnotation bool ignoreIngressTLSSpec bool ignoreIngressRulesSpec bool ingressLabelSelector labels.Selector ingressClassNames []string }{ { title: "no ingress", targetNamespace: "", }, { title: "two simple ingresses", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, { name: "fake2", namespace: namespace, dnsnames: []string{"new.org"}, hostnames: []string{"lb.com"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "ipv6 ingress", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, dnsnames: []string{"example.org"}, ips: []string{"2001:DB8::1"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, }, }, { title: "dualstack ingress", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8", "2001:DB8::1"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}, }, }, }, { title: "ignore rules", targetNamespace: "", ignoreIngressRulesSpec: true, ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, { name: "fake2", namespace: namespace, dnsnames: []string{"new.org"}, hostnames: []string{"lb.com"}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "two simple ingresses on different namespaces", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: "testing1", dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, { name: "fake2", namespace: "testing2", dnsnames: []string{"new.org"}, hostnames: []string{"lb.com"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "two simple ingresses on different namespaces with target namespace", targetNamespace: "testing1", ingressItems: []fakeIngress{ { name: "fake1", namespace: "testing1", dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, { name: "fake2", namespace: "testing2", dnsnames: []string{"new.org"}, hostnames: []string{"lb.com"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "valid matching annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/ingress.class in (alb, nginx)", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "kubernetes.io/ingress.class": "nginx", }, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "valid non-matching annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/ingress.class in (alb, nginx)", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "kubernetes.io/ingress.class": "tectonic", }, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "invalid annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/ingress.name in (a b)", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "kubernetes.io/ingress.class": "alb", }, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{}, expectError: true, }, { title: "valid matching annotation filter label", targetNamespace: "", annotationFilter: "kubernetes.io/ingress.class=nginx", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "kubernetes.io/ingress.class": "nginx", }, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "valid non-matching annotation filter label", targetNamespace: "", annotationFilter: "kubernetes.io/ingress.class=nginx", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ "kubernetes.io/ingress.class": "alb", }, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "our controller type is dns-controller", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "different controller types are ignored", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: "some-other-tool", }, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "template for ingress if host is missing", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, dnsnames: []string{}, ips: []string{"8.8.8.8"}, hostnames: []string{"elb.com"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "fake1.ext-dns.test.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com"}, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "another controller annotation skipped even with template", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: "other-controller", }, dnsnames: []string{}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "multiple FQDN template hostnames", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{}, dnsnames: []string{}, ips: []string{"8.8.8.8"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "fake1.ext-dna.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", }, { title: "multiple FQDN template hostnames", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{}, dnsnames: []string{}, ips: []string{"8.8.8.8"}, }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", }, dnsnames: []string{"example.org"}, ips: []string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "fake1.ext-dna.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example.org", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dns.test.com", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dna.test.com", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", combineFQDNAndAnnotation: true, }, { title: "ingress rules with annotation", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", }, dnsnames: []string{"example.org"}, ips: []string{}, }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", }, dnsnames: []string{"example2.org"}, ips: []string{"8.8.8.8"}, }, { name: "fake3", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "1.2.3.4", }, dnsnames: []string{"example3.org"}, ips: []string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example2.org", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example3.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "ingress rules with single tls having single hostname", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, tlsdnsnames: [][]string{{"example.org"}}, ips: []string{"1.2.3.4"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "ingress rules with single tls having multiple hostnames", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, tlsdnsnames: [][]string{{"example.org", "example2.org"}}, ips: []string{"1.2.3.4"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example2.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "ingress rules with multiple tls having multiple hostnames", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, tlsdnsnames: [][]string{{"example.org", "example2.org"}, {"example3.org", "example4.org"}}, ips: []string{"1.2.3.4"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example2.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example3.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example4.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "ingress rules with hostname annotation", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com", }, dnsnames: []string{"example.org"}, ips: []string{"1.2.3.4"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "ingress rules with hostname annotation having multiple hostnames", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com, another-dns-through-hostname.com", }, dnsnames: []string{"example.org"}, ips: []string{"1.2.3.4"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "another-dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "ingress rules with hostname and target annotation", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com", annotations.TargetKey: "ingress-target.com", }, dnsnames: []string{"example.org"}, ips: []string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, { title: "ingress rules with annotation and custom TTL", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", annotations.TtlKey: "6", }, dnsnames: []string{"example.org"}, ips: []string{}, }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", annotations.TtlKey: "1", }, dnsnames: []string{"example2.org"}, ips: []string{"8.8.8.8"}, }, { name: "fake3", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", annotations.TtlKey: "10s", }, dnsnames: []string{"example3.org"}, ips: []string{"8.8.4.4"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"ingress-target.com"}, RecordTTL: endpoint.TTL(6), }, { DNSName: "example2.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"ingress-target.com"}, RecordTTL: endpoint.TTL(1), }, { DNSName: "example3.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"ingress-target.com"}, RecordTTL: endpoint.TTL(10), }, }, }, { title: "ingress rules with alias and target annotation", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", annotations.AliasKey: "true", }, dnsnames: []string{"example.org"}, ips: []string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, ProviderSpecific: endpoint.ProviderSpecific{{ Name: "alias", Value: "true", }}, }, }, }, { title: "ingress rules with alias set false and target annotation", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", annotations.AliasKey: "false", }, dnsnames: []string{"example.org"}, ips: []string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, { title: "template for ingress with annotation", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", }, dnsnames: []string{}, ips: []string{}, hostnames: []string{}, }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "ingress-target.com", }, dnsnames: []string{}, ips: []string{"8.8.8.8"}, }, { name: "fake3", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "1.2.3.4", }, dnsnames: []string{}, ips: []string{}, hostnames: []string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dns.test.com", Targets: endpoint.Targets{"ingress-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake3.ext-dns.test.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "Ingress with empty annotation", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "", }, dnsnames: []string{}, ips: []string{}, hostnames: []string{}, }, }, expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "ignore hostname annotation", targetNamespace: "", ignoreHostnameAnnotation: true, ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com", }, dnsnames: []string{"new.org"}, hostnames: []string{"lb.com"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "ignore tls section", targetNamespace: "", ignoreIngressTLSSpec: true, ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, tlsdnsnames: [][]string{{"example.org"}}, ips: []string{"1.2.3.4"}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "reading tls section", targetNamespace: "", ignoreIngressTLSSpec: false, ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, tlsdnsnames: [][]string{{"example.org"}}, ips: []string{"1.2.3.4"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, }, }, { title: "ingressClassName filtering", targetNamespace: "", ingressClassNames: []string{"public", "dmz"}, ingressItems: []fakeIngress{ { name: "none", namespace: namespace, tlsdnsnames: [][]string{{"none.example.org"}}, ips: []string{"1.0.0.0"}, }, { name: "fake-public", namespace: namespace, tlsdnsnames: [][]string{{"example.org"}}, ips: []string{"1.2.3.4"}, ingressClassName: "public", // match }, { name: "fake-internal", namespace: namespace, tlsdnsnames: [][]string{{"int.example.org"}}, ips: []string{"2.3.4.5"}, ingressClassName: "internal", }, { name: "fake-dmz", namespace: namespace, tlsdnsnames: [][]string{{"dmz.example.org"}}, ips: []string{"3.4.5.6"}, ingressClassName: "dmz", // match }, { name: "annotated-dmz", namespace: namespace, tlsdnsnames: [][]string{{"annodmz.example.org"}}, ips: []string{"4.5.6.7"}, annotations: map[string]string{ "kubernetes.io/ingress.class": "dmz", // match }, }, { name: "fake-internal-annotated-dmz", namespace: namespace, tlsdnsnames: [][]string{{"int-annodmz.example.org"}}, ips: []string{"5.6.7.8"}, annotations: map[string]string{ "kubernetes.io/ingress.class": "dmz", // match but ignored (non-empty ingressClassName) }, ingressClassName: "internal", }, { name: "fake-dmz-annotated-internal", namespace: namespace, tlsdnsnames: [][]string{{"dmz-annoint.example.org"}}, ips: []string{"6.7.8.9"}, annotations: map[string]string{ "kubernetes.io/ingress.class": "internal", }, ingressClassName: "dmz", // match }, { name: "empty-annotated-dmz", namespace: namespace, tlsdnsnames: [][]string{{"empty-annotdmz.example.org"}}, ips: []string{"7.8.9.0"}, annotations: map[string]string{ "kubernetes.io/ingress.class": "dmz", // match (empty ingressClassName) }, ingressClassName: "", }, { name: "empty-annotated-internal", namespace: namespace, tlsdnsnames: [][]string{{"empty-annotint.example.org"}}, ips: []string{"8.9.0.1"}, annotations: map[string]string{ "kubernetes.io/ingress.class": "internal", }, ingressClassName: "", }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "dmz.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"3.4.5.6"}, }, { DNSName: "annodmz.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"4.5.6.7"}, }, { DNSName: "dmz-annoint.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"6.7.8.9"}, }, { DNSName: "empty-annotdmz.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"7.8.9.0"}, }, }, }, { ingressLabelSelector: labels.SelectorFromSet(labels.Set{"app": "web-external"}), title: "ingress with matching labels", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, labels: map[string]string{"app": "web-external", "name": "reverse-proxy"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { ingressLabelSelector: labels.SelectorFromSet(labels.Set{"app": "web-external"}), title: "ingress without matching labels", targetNamespace: "", ingressItems: []fakeIngress{ { name: "fake1", namespace: namespace, dnsnames: []string{"example.org"}, ips: []string{"8.8.8.8"}, labels: map[string]string{"app": "web-internal", "name": "reverse-proxy"}, }, }, expected: []*endpoint.Endpoint{}, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeClient := fake.NewClientset() for _, item := range ti.ingressItems { ingress := item.Ingress() _, err := fakeClient.NetworkingV1().Ingresses(ingress.Namespace).Create(t.Context(), ingress, metav1.CreateOptions{}) require.NoError(t, err) } if ti.ingressLabelSelector == nil { ti.ingressLabelSelector = labels.Everything() } source, _ := NewIngressSource( t.Context(), fakeClient, &Config{ Namespace: ti.targetNamespace, AnnotationFilter: ti.annotationFilter, FQDNTemplate: ti.fqdnTemplate, CombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation, IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, IgnoreIngressTLSSpec: ti.ignoreIngressTLSSpec, IgnoreIngressRulesSpec: ti.ignoreIngressRulesSpec, LabelFilter: ti.ingressLabelSelector, IngressClassNames: ti.ingressClassNames, }, ) // Informer cache has all of the ingresses. Retrieve and validate their endpoints. res, err := source.Endpoints(t.Context()) if ti.expectError { require.Error(t, err) } else { require.NoError(t, err) } validateEndpoints(t, res, ti.expected) // TODO; when all resources have the resource label, we could add this check to the validateEndpoints function. for _, ep := range res { require.Contains(t, ep.Labels, endpoint.ResourceLabelKey) } }) } } // ingress specific helper functions type fakeIngress struct { dnsnames []string tlsdnsnames [][]string ips []string hostnames []string namespace string name string annotations map[string]string labels map[string]string ingressClassName string } func (ing fakeIngress) Ingress() *networkv1.Ingress { ingress := &networkv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Namespace: ing.namespace, Name: ing.name, Annotations: ing.annotations, Labels: ing.labels, }, Spec: networkv1.IngressSpec{ Rules: []networkv1.IngressRule{}, IngressClassName: &ing.ingressClassName, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{}, }, }, } for _, dnsname := range ing.dnsnames { ingress.Spec.Rules = append(ingress.Spec.Rules, networkv1.IngressRule{ Host: dnsname, }) } for _, hosts := range ing.tlsdnsnames { ingress.Spec.TLS = append(ingress.Spec.TLS, networkv1.IngressTLS{ Hosts: hosts, }) } for _, ip := range ing.ips { ingress.Status.LoadBalancer.Ingress = append(ingress.Status.LoadBalancer.Ingress, networkv1.IngressLoadBalancerIngress{ IP: ip, }) } for _, hostname := range ing.hostnames { ingress.Status.LoadBalancer.Ingress = append(ingress.Status.LoadBalancer.Ingress, networkv1.IngressLoadBalancerIngress{ Hostname: hostname, }) } return ingress } func TestIngressWithConfiguration(t *testing.T) { for _, tt := range []struct { title string ingresses []*networkv1.Ingress cfg *Config expected []*endpoint.Endpoint }{ { title: "hostname and targets configured as annotations", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", Annotations: map[string]string{ annotations.HostnameKey: "bla.example.org", annotations.TargetKey: "target.example.org", }, }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("nginx"), Rules: []networkv1.IngressRule{ {Host: "app.example.com"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {IP: "1.2.3.4"}, }, }, }, }, }, expected: []*endpoint.Endpoint{ {DNSName: "app.example.com", Targets: endpoint.Targets{"target.example.org"}, RecordType: endpoint.RecordTypeCNAME}, {DNSName: "bla.example.org", Targets: endpoint.Targets{"target.example.org"}, RecordType: endpoint.RecordTypeCNAME}, }, }, { title: "ingress with spec.tls with wildcard domains and tls not ignored", cfg: &Config{ IgnoreIngressTLSSpec: false, }, ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", Annotations: map[string]string{}, }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("alb"), TLS: []networkv1.IngressTLS{ { Hosts: []string{"*.example.com"}, }, }, Rules: []networkv1.IngressRule{ {Host: "abc.example.com"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {IP: "1.2.3.4"}, }, }, }, }, }, expected: []*endpoint.Endpoint{ {DNSName: "abc.example.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA}, {DNSName: "*.example.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA}, }, }, { title: "ingress with spec.tls with wildcard domains and tls is ignored", cfg: &Config{ IgnoreIngressTLSSpec: true, }, ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("alb"), TLS: []networkv1.IngressTLS{ { Hosts: []string{"*.example.com"}, }, }, Rules: []networkv1.IngressRule{ {Host: "abc.example.com"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {IP: "1.2.3.4"}, }, }, }, }, }, expected: []*endpoint.Endpoint{ {DNSName: "abc.example.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA}, }, }, { title: "ingress with when AWS ALB controller and NLB type generates two targets for CNAME", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", Annotations: map[string]string{ "alb.ingress.kubernetes.io/enable-frontend-nlb": "true", "alb.ingress.kubernetes.io/frontend-nlb-scheme": "internal", }, }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("alb"), Rules: []networkv1.IngressRule{ {Host: "some.subdomain.mydomain.com"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {Hostname: "internal-k8s-some-domain.us-east-1.elb.amazonaws.com"}, {Hostname: "k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com"}, }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "some.subdomain.mydomain.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{ "internal-k8s-some-domain.us-east-1.elb.amazonaws.com", "k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com", }, }, }, }, { title: "ingress with when AWS ALB controller and NLB with target annotation and CNAME with single target", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", Annotations: map[string]string{ "alb.ingress.kubernetes.io/enable-frontend-nlb": "true", "alb.ingress.kubernetes.io/frontend-nlb-scheme": "internal", annotations.TargetKey: "k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com", }, }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("alb"), Rules: []networkv1.IngressRule{ {Host: "some.subdomain.mydomain.com"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {Hostname: "internal-k8s-some-domain.us-east-1.elb.amazonaws.com"}, {Hostname: "k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com"}, }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "some.subdomain.mydomain.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"k8s-another-domain-nlb-123456789.elb.us-east-1.amazonaws.com"}, }, }, }, { title: "no annotations, multiple ingresses, mixed IP and Hostname targets", ingresses: []*networkv1.Ingress{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-ingress", Namespace: "default", }, Spec: networkv1.IngressSpec{ IngressClassName: testutils.ToPtr("alb"), Rules: []networkv1.IngressRule{ {Host: "app.example.com"}, }, }, Status: networkv1.IngressStatus{ LoadBalancer: networkv1.IngressLoadBalancerStatus{ Ingress: []networkv1.IngressLoadBalancerIngress{ {IP: "1.2.3.4"}, {Hostname: "foo.tld"}, }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "app.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "app.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"foo.tld"}, }, }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() for _, el := range tt.ingresses { _, err := kubeClient.NetworkingV1().Ingresses(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{}) require.NoError(t, err) } if tt.cfg == nil { tt.cfg = &Config{} } tt.cfg.LabelFilter = labels.Everything() src, err := NewIngressSource( t.Context(), kubeClient, tt.cfg, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } func TestProcessEndpoint_Ingress_RefObjectExist(t *testing.T) { elements := []runtime.Object{ &networkv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: "foo", Annotations: map[string]string{ annotations.HostnameKey: "foo.example.com", annotations.TargetKey: "1.2.3", }, UID: "uid-1", }, }, &networkv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: "bar", Annotations: map[string]string{ annotations.HostnameKey: "bar.example.com", annotations.TargetKey: "3.4.5", }, UID: "uid-2", }, }, } fakeClient := fake.NewClientset(elements...) client, err := NewIngressSource( t.Context(), fakeClient, &Config{ LabelFilter: labels.Everything(), }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) require.NoError(t, err) testutils.AssertEndpointsHaveRefObject(t, endpoints, types.Ingress, len(elements)) } ================================================ FILE: source/istio_gateway.go ================================================ /* Copyright 2017 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 source import ( "context" "fmt" "strings" "text/template" log "github.com/sirupsen/logrus" networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" istioclient "istio.io/client-go/pkg/clientset/versioned" istioinformers "istio.io/client-go/pkg/informers/externalversions" networkingv1beta1informer "istio.io/client-go/pkg/informers/externalversions/networking/v1beta1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" netinformers "k8s.io/client-go/informers/networking/v1" "k8s.io/client-go/kubernetes" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" ) // IstioGatewayIngressSource is the annotation used to determine if the gateway is implemented by an Ingress object // instead of a standard LoadBalancer service type // Using var instead of const because annotation keys can be customized var IstioGatewayIngressSource = annotations.Ingress // gatewaySource is an implementation of Source for Istio Gateway objects. // The gateway implementation uses the spec.servers.hosts values for the hostnames. // Use annotations.TargetKey to explicitly set Endpoint. // // +externaldns:source:name=istio-gateway // +externaldns:source:category=Service Mesh // +externaldns:source:description=Creates DNS entries from Istio Gateway resources // +externaldns:source:resources=Gateway.networking.istio.io // +externaldns:source:filters=annotation // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true type gatewaySource struct { kubeClient kubernetes.Interface istioClient istioclient.Interface namespace string annotationFilter string fqdnTemplate *template.Template combineFQDNAnnotation bool ignoreHostnameAnnotation bool serviceInformer coreinformers.ServiceInformer gatewayInformer networkingv1beta1informer.GatewayInformer ingressInformer netinformers.IngressInformer } // NewIstioGatewaySource creates a new gatewaySource with the given config. func NewIstioGatewaySource( ctx context.Context, kubeClient kubernetes.Interface, istioClient istioclient.Interface, cfg *Config, ) (Source, error) { tmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } // Use shared informers to listen for add/update/delete of services/pods/nodes in the specified namespace. // Set resync period to 0, to prevent processing when nothing has changed informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(cfg.Namespace)) serviceInformer := informerFactory.Core().V1().Services() istioInformerFactory := istioinformers.NewSharedInformerFactory(istioClient, 0) gatewayInformer := istioInformerFactory.Networking().V1beta1().Gateways() ingressInformer := informerFactory.Networking().V1().Ingresses() _, _ = ingressInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) // Add default resource event handlers to properly initialize informer. _, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) err = serviceInformer.Informer().SetTransform(informers.TransformerWithOptions[*corev1.Service]( informers.TransformWithSpecSelector(), informers.TransformWithSpecExternalIPs(), informers.TransformWithStatusLoadBalancer(), )) if err != nil { return nil, err } _, _ = gatewayInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) istioInformerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } if err := informers.WaitForCacheSync(ctx, istioInformerFactory); err != nil { return nil, err } return &gatewaySource{ kubeClient: kubeClient, istioClient: istioClient, namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, fqdnTemplate: tmpl, combineFQDNAnnotation: cfg.CombineFQDNAndAnnotation, ignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, serviceInformer: serviceInformer, gatewayInformer: gatewayInformer, ingressInformer: ingressInformer, }, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all gateway resources in the source's namespace(s). func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { gwList, err := sc.istioClient.NetworkingV1beta1().Gateways(sc.namespace).List(ctx, metav1.ListOptions{}) if err != nil { return nil, err } gateways := gwList.Items gateways, err = annotations.Filter(gateways, sc.annotationFilter) if err != nil { return nil, err } var endpoints []*endpoint.Endpoint log.Debugf("Found %d gateways in namespace %s", len(gateways), sc.namespace) for _, gateway := range gateways { if annotations.IsControllerMismatch(gateway, types.IstioGateway) { continue } gwHostnames := sc.hostNamesFromGateway(gateway) log.Debugf("Processing gateway '%s/%s.%s' and hosts %q", gateway.Namespace, gateway.APIVersion, gateway.Name, strings.Join(gwHostnames, ",")) gwEndpoints, err := sc.endpointsFromGateway(gwHostnames, gateway) if err != nil { return nil, err } // apply template if host is missing on gateway gwEndpoints, err = fqdn.CombineWithTemplatedEndpoints( gwEndpoints, sc.fqdnTemplate, sc.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, gateway) if err != nil { return nil, err } return sc.endpointsFromGateway(hostnames, gateway) }, ) if err != nil { return nil, err } if endpoint.HasNoEmptyEndpoints(gwEndpoints, types.IstioGateway, gateway) { continue } log.Debugf("Endpoints generated from %q '%s/%s.%s': %q", gateway.Kind, gateway.Namespace, gateway.APIVersion, gateway.Name, gwEndpoints) endpoints = append(endpoints, gwEndpoints...) } return MergeEndpoints(endpoints), nil } // AddEventHandler adds an event handler that should be triggered if the watched Istio Gateway changes. func (sc *gatewaySource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for Istio Gateway") _, _ = sc.gatewayInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } func (sc *gatewaySource) targetsFromIngress(ingressStr string, gateway *networkingv1beta1.Gateway) (endpoint.Targets, error) { namespace, name, err := ParseIngress(ingressStr) if err != nil { return nil, fmt.Errorf("failed to parse Ingress annotation on Gateway (%s/%s): %w", gateway.Namespace, gateway.Name, err) } if namespace == "" { namespace = gateway.Namespace } targets := make(endpoint.Targets, 0) ingress, err := sc.ingressInformer.Lister().Ingresses(namespace).Get(name) if err != nil { log.Error(err) return nil, err } for _, lb := range ingress.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } else if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } return targets, nil } func (sc *gatewaySource) targetsFromGateway(gateway *networkingv1beta1.Gateway) (endpoint.Targets, error) { targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) > 0 { return targets, nil } ingressStr, ok := gateway.Annotations[IstioGatewayIngressSource] if ok && ingressStr != "" { return sc.targetsFromIngress(ingressStr, gateway) } return EndpointTargetsFromServices(sc.serviceInformer, sc.namespace, gateway.Spec.Selector) } // endpointsFromGatewayConfig extracts the endpoints from an Istio Gateway Config object func (sc *gatewaySource) endpointsFromGateway(hostnames []string, gateway *networkingv1beta1.Gateway) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint var err error targets, err := sc.targetsFromGateway(gateway) if err != nil { return nil, err } if len(targets) == 0 { return endpoints, nil } resource := fmt.Sprintf("gateway/%s/%s", gateway.Namespace, gateway.Name) ttl := annotations.TTLFromAnnotations(gateway.Annotations, resource) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(gateway.Annotations) for _, host := range hostnames { endpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } func (sc *gatewaySource) hostNamesFromGateway(gateway *networkingv1beta1.Gateway) []string { var hostnames []string for _, server := range gateway.Spec.Servers { for _, host := range server.Hosts { if host == "" { continue } parts := strings.Split(host, "/") // If the input hostname is of the form my-namespace/foo.bar.com, remove the namespace // before appending it to the list of endpoints to create if len(parts) == 2 { host = parts[1] } if host != "*" { hostnames = append(hostnames, host) } } } if !sc.ignoreHostnameAnnotation { hostnames = append(hostnames, annotations.HostnamesFromAnnotations(gateway.Annotations)...) } return hostnames } ================================================ FILE: source/istio_gateway_fqdn_test.go ================================================ /* Copyright 2026 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 source import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" istionetworking "istio.io/api/networking/v1beta1" networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" istiofake "istio.io/client-go/pkg/clientset/versioned/fake" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/endpoint" ) func TestIstioGatewaySourceNewSourceWithFqdn(t *testing.T) { for _, tt := range []struct { title string annotationFilter string fqdnTemplate string expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "valid template with multiple hosts", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", }, } { t.Run(tt.title, func(t *testing.T) { _, err := NewIstioGatewaySource( t.Context(), fake.NewClientset(), istiofake.NewSimpleClientset(), &Config{ Namespace: "", AnnotationFilter: tt.annotationFilter, FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: false, IgnoreHostnameAnnotation: false, }, ) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func TestIstioGatewaySourceFqdnTemplatingExamples(t *testing.T) { for _, tt := range []struct { title string gateways []*networkingv1beta1.Gateway services []*v1.Service fqdnTemplate string combineFqdn bool expected []*endpoint.Endpoint }{ { title: "simple templating with gateway name", fqdnTemplate: "{{.Name}}.test.com", expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "my-gateway.test.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "istio-system", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"example.org"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "istio-system", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "1.2.3.4"}}, }, }, }, }, }, { title: "templating with fqdn combine disabled", fqdnTemplate: "{{.Name}}.test.com", expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, combineFqdn: true, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "istio-system", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"example.org"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "istio-system", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "1.2.3.4"}}, }, }, }, }, }, { title: "templating with namespace", fqdnTemplate: "{{.Name}}.{{.Namespace}}.cluster.local", expected: []*endpoint.Endpoint{ {DNSName: "api.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, {DNSName: "api-gateway.kube-system.cluster.local", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"::ffff:192.1.56.10"}}, {DNSName: "api-gateway.production.cluster.local", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "api-gateway", Namespace: "production", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"api.example.org"}}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "api-gateway", Namespace: "kube-system", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway-extra"}, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "production", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "5.6.7.8"}}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "kube-metrics-server", Namespace: "kube-system", Labels: map[string]string{"istio": "ingressgateway-extra"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway-extra"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "::ffff:192.1.56.10"}}, }, }, }, }, }, { title: "templating with complex fqdn template", fqdnTemplate: "{{.Name}}.example.com,{{.Name}}.example.org", expected: []*endpoint.Endpoint{ {DNSName: "multi-gateway.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, {DNSName: "multi-gateway.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "multi-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{}, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "10.0.0.1"}}, }, }, }, }, }, { title: "combine FQDN annotation with template", fqdnTemplate: "{{.Name}}.internal.example.com", expected: []*endpoint.Endpoint{ {DNSName: "app.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, {DNSName: "combined-gateway.internal.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "combined-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"app.example.org"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{ "istio": "ingressgateway", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "172.16.0.1"}}, }, }, }, }, }, { title: "templating with labels", gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "labeled-gateway", Namespace: "default", Labels: map[string]string{ "environment": "staging", }, Annotations: map[string]string{ annotations.TargetKey: "203.0.113.1", }, }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{}, }, }, }, fqdnTemplate: "{{.Name}}.{{.Labels.environment}}.example.com", expected: []*endpoint.Endpoint{ {DNSName: "labeled-gateway.staging.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.0.113.1"}}, }, }, { title: "srv record with node port and cluster ip services without external ips", fqdnTemplate: "{{.Name}}.example.com", expected: []*endpoint.Endpoint{}, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "labeled-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{}, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-node-port", Namespace: "default", Labels: map[string]string{ "istio": "ingressgateway", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeNodePort, Selector: map[string]string{"istio": "ingressgateway"}, ClusterIP: "10.96.41.133", Ports: []v1.ServicePort{ {Name: "dns", Port: 8082, TargetPort: intstr.FromInt32(8083), Protocol: v1.ProtocolUDP, NodePort: 30083}, {Name: "dns-tcp", Port: 2525, TargetPort: intstr.FromInt32(25256), NodePort: 25565}, }, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{}, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "test-cluster-ip", Namespace: "default", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, Selector: map[string]string{"istio": "ingressgateway"}, ClusterIP: "10.96.41.133", Ports: []v1.ServicePort{ {Name: "dns", Port: 53, TargetPort: intstr.FromInt32(30053), Protocol: v1.ProtocolUDP}, {Name: "dns-tcp", Port: 53, TargetPort: intstr.FromInt32(30054), NodePort: 25565}, }, }, }, }, }, { title: "srv record with node port and cluster ip services with external ips", fqdnTemplate: "{{.Name}}.tld.org", expected: []*endpoint.Endpoint{ {DNSName: "nodeport-external.tld.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "nodeport-external", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-node-port", Namespace: "default", Labels: map[string]string{ "istio": "ingressgateway", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeNodePort, Selector: map[string]string{"istio": "ingressgateway"}, ClusterIP: "10.96.41.133", ExternalIPs: []string{"192.168.132.253"}, Ports: []v1.ServicePort{ {Name: "dns", Port: 8082, TargetPort: intstr.FromInt32(8083), Protocol: v1.ProtocolUDP, NodePort: 30083}, {Name: "dns-tcp", Port: 2525, TargetPort: intstr.FromInt32(25256), NodePort: 25565}, }, }, }, }, }, { title: "with host as subdomain in reversed order", fqdnTemplate: "{{ range $server := .Spec.Servers }}{{ range $host := $server.Hosts }}{{ $host }}.{{ $server.Port.Name }}.{{ $server.Port.Number }}.tld.com,{{ end }}{{ end }}", expected: []*endpoint.Endpoint{ {DNSName: "www.bookinfo", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, {DNSName: "bookinfo", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, {DNSName: "www.bookinfo.http.443.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, {DNSName: "bookinfo.dns.8080.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.132.253"}}, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "nodeport-external", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ { Hosts: []string{"www.bookinfo"}, Name: "main", Port: &istionetworking.Port{Number: 443, Name: "http", Protocol: "HTTPS"}, }, { Hosts: []string{"bookinfo"}, Name: "debug", Port: &istionetworking.Port{Number: 8080, Name: "dns", Protocol: "UDP"}, }, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "test-node-port", Namespace: "default", Labels: map[string]string{ "istio": "ingressgateway", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeNodePort, Selector: map[string]string{"istio": "ingressgateway"}, ClusterIP: "10.96.41.133", ExternalIPs: []string{"192.168.132.253"}, }, }, }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() istioClient := istiofake.NewSimpleClientset() for _, svc := range tt.services { _, err := kubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) } for _, gw := range tt.gateways { _, err := istioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewIstioGatewaySource( t.Context(), kubeClient, istioClient, &Config{ Namespace: "", AnnotationFilter: "", FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: !tt.combineFqdn, IgnoreHostnameAnnotation: false, }, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } ================================================ FILE: source/istio_gateway_test.go ================================================ /* Copyright 2017 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 source import ( "context" "errors" "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" istionetworking "istio.io/api/networking/v1beta1" networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" istiofake "istio.io/client-go/pkg/clientset/versioned/fake" v1 "k8s.io/api/core/v1" networkv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/endpoint" ) // This is a compile-time validation that gatewaySource is a Source. var _ Source = &gatewaySource{} type GatewaySuite struct { suite.Suite source Source lbServices []*v1.Service ingresses []*networkv1.Ingress } func (suite *GatewaySuite) SetupTest() { fakeKubernetesClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() var err error suite.lbServices = []*v1.Service{ (fakeIngressGatewayService{ ips: []string{"8.8.8.8"}, hostnames: []string{"v1"}, namespace: "istio-system", name: "istio-gateway1", }).Service(), (fakeIngressGatewayService{ ips: []string{"1.1.1.1"}, hostnames: []string{"v42"}, namespace: "istio-other", name: "istio-gateway2", }).Service(), } for _, service := range suite.lbServices { _, err = fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(context.Background(), service, metav1.CreateOptions{}) suite.NoError(err, "should succeed") } suite.ingresses = []*networkv1.Ingress{ (fakeIngress{ ips: []string{"2.2.2.2"}, hostnames: []string{"v2"}, namespace: "istio-system", name: "istio-ingress", }).Ingress(), (fakeIngress{ ips: []string{"3.3.3.3"}, hostnames: []string{"v62"}, namespace: "istio-system", name: "istio-ingress2", }).Ingress(), } for _, ingress := range suite.ingresses { _, err = fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{}) suite.NoError(err, "should succeed") } suite.source, err = NewIstioGatewaySource( context.TODO(), fakeKubernetesClient, fakeIstioClient, &Config{ FQDNTemplate: "{{.Name}}", }, ) suite.NoError(err, "should initialize gateway source") suite.NoError(err, "should succeed") } func (suite *GatewaySuite) TestResourceLabelIsSet() { endpoints, _ := suite.source.Endpoints(context.Background()) for _, ep := range endpoints { suite.Equal("gateway/default/foo-gateway-with-targets", ep.Labels[endpoint.ResourceLabelKey], "should set correct resource label") } } func TestGateway(t *testing.T) { t.Parallel() suite.Run(t, new(GatewaySuite)) t.Run("endpointsFromGatewayConfig", testEndpointsFromGatewayConfig) t.Run("Endpoints", testGatewayEndpoints) } func TestNewIstioGatewaySource(t *testing.T) { t.Parallel() for _, ti := range []struct { title string annotationFilter string fqdnTemplate string combineFQDNAndAnnotation bool expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", combineFQDNAndAnnotation: true, }, { title: "non-empty annotation filter label", expectError: false, annotationFilter: "kubernetes.io/gateway.class=nginx", }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() _, err := NewIstioGatewaySource( t.Context(), fake.NewClientset(), istiofake.NewSimpleClientset(), &Config{ FQDNTemplate: ti.fqdnTemplate, CombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation, AnnotationFilter: ti.annotationFilter, }, ) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func testEndpointsFromGatewayConfig(t *testing.T) { t.Parallel() for _, ti := range []struct { title string lbServices []fakeIngressGatewayService ingresses []fakeIngress config fakeGatewayConfig expected []*endpoint.Endpoint }{ { title: "one rule.host one lb.hostname", lbServices: []fakeIngressGatewayService{ { hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ {"foo.bar"}, // Kubernetes requires removal of trailing dot }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "one namespaced rule.host one lb.hostname", lbServices: []fakeIngressGatewayService{ { hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ {"my-namespace/foo.bar"}, // Kubernetes requires removal of trailing dot }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "one rule.host one lb.IP", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ {"foo.bar"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "one rule.host one ingress.IP", ingresses: []fakeIngress{ { name: "ingress1", ips: []string{"8.8.8.8"}, }, }, config: fakeGatewayConfig{ annotations: map[string]string{ IstioGatewayIngressSource: "ingress1", }, dnsnames: [][]string{ {"foo.bar"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "one rule.host two lb.IP and two lb.Hostname", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ {"foo.bar"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8", "127.0.0.1"}, }, { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com", "alb.com"}, }, }, }, { title: "one rule.host two ingress.IP and two ingress.Hostname", ingresses: []fakeIngress{ { name: "ingress1", ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, }, }, config: fakeGatewayConfig{ annotations: map[string]string{ IstioGatewayIngressSource: "ingress1", }, dnsnames: [][]string{ {"foo.bar"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8", "127.0.0.1"}, }, { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com", "alb.com"}, }, }, }, { title: "no rule.host", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, externalIPs: []string{"1.1.1.1", "2.2.2.2"}, }, }, config: fakeGatewayConfig{ dnsnames: [][]string{}, }, expected: []*endpoint.Endpoint{}, }, { title: "one empty rule.host", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, externalIPs: []string{"1.1.1.1", "2.2.2.2"}, }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ {""}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "one empty rule.host with gateway ingress annotation", ingresses: []fakeIngress{ { name: "ingress1", ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, }, }, config: fakeGatewayConfig{ annotations: map[string]string{ IstioGatewayIngressSource: "ingress1", }, dnsnames: [][]string{ {""}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "no targets", lbServices: []fakeIngressGatewayService{{}}, config: fakeGatewayConfig{ dnsnames: [][]string{ {""}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "one gateway, two ingressgateway loadbalancer hostnames", lbServices: []fakeIngressGatewayService{ { hostnames: []string{"lb.com"}, namespace: "istio-other", name: "gateway1", }, { hostnames: []string{"lb2.com"}, namespace: "istio-other", name: "gateway2", }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ {"foo.bar"}, // Kubernetes requires removal of trailing dot }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com", "lb2.com"}, }, }, }, { title: "one gateway, ingress in separate namespace", ingresses: []fakeIngress{ { hostnames: []string{"lb.com"}, namespace: "istio-other2", name: "ingress1", }, { hostnames: []string{"lb2.com"}, namespace: "istio-other", name: "ingress2", }, }, config: fakeGatewayConfig{ annotations: map[string]string{ IstioGatewayIngressSource: "istio-other2/ingress1", }, dnsnames: [][]string{ {"foo.bar"}, // Kubernetes requires removal of trailing dot }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "one rule.host one lb.externalIP", lbServices: []fakeIngressGatewayService{ { externalIPs: []string{"8.8.8.8"}, }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ {"foo.bar"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "one rule.host two lb.IP, two lb.Hostname and two lb.externalIP", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, externalIPs: []string{"1.1.1.1", "2.2.2.2"}, }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ {"foo.bar"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "2.2.2.2"}, }, }, }, { title: "provider-specific annotation is converted to endpoint property", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, config: fakeGatewayConfig{ annotations: map[string]string{ annotations.AWSPrefix + "weight": "10", }, dnsnames: [][]string{ {"foo.bar"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() gatewayCfg := ti.config.Config() source, err := newTestGatewaySource(ti.lbServices, ti.ingresses) require.NoError(t, err) hostnames := source.hostNamesFromGateway(gatewayCfg) endpoints, err := source.endpointsFromGateway(hostnames, gatewayCfg) require.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } func testGatewayEndpoints(t *testing.T) { t.Parallel() for _, ti := range []struct { title string targetNamespace string annotationFilter string lbServices []fakeIngressGatewayService ingresses []fakeIngress configItems []fakeGatewayConfig expected []*endpoint.Endpoint expectError bool fqdnTemplate string combineFQDNAndAnnotation bool ignoreHostnameAnnotation bool }{ { title: "no gateway", targetNamespace: "", }, { title: "two simple gateways, one ingressgateway loadbalancer service", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", dnsnames: [][]string{{"example.org"}}, }, { name: "fake2", namespace: "", dnsnames: [][]string{{"new.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "two simple gateways on different namespaces, one ingressgateway loadbalancer service", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", dnsnames: [][]string{{"example.org"}}, }, { name: "fake2", namespace: "", dnsnames: [][]string{{"new.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "two simple gateways on different namespaces and a target namespace, one ingressgateway loadbalancer service", targetNamespace: "testing1", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, namespace: "testing1", }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "testing1", dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "one simple gateways on different namespace and a target namespace, one ingress service", targetNamespace: "testing1", ingresses: []fakeIngress{ { name: "ingress1", ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, namespace: "testing2", }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "testing1", dnsnames: [][]string{{"example.org"}}, annotations: map[string]string{ IstioGatewayIngressSource: "testing2/ingress1", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "valid matching annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.class in (alb, nginx)", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ "kubernetes.io/gateway.class": "nginx", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "valid non-matching annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.class in (alb, nginx)", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ "kubernetes.io/gateway.class": "tectonic", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "invalid annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.name in (a b)", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ "kubernetes.io/gateway.class": "alb", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{}, expectError: true, }, { title: "valid matching annotation filter label", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.class=nginx", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ "kubernetes.io/gateway.class": "nginx", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "valid non-matching annotation filter label", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.class=nginx", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ "kubernetes.io/gateway.class": "alb", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "our controller type is dns-controller", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "different controller types are ignored", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.ControllerKey: "some-other-tool", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "template for gateway if host is missing", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"elb.com"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, dnsnames: [][]string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "fake1.ext-dns.test.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com"}, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "another controller annotation skipped even with template", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.ControllerKey: "other-controller", }, dnsnames: [][]string{}, }, }, expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "multiple FQDN template hostnames", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{}, dnsnames: [][]string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "fake1.ext-dna.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", }, { title: "multiple FQDN template hostnames", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{}, dnsnames: [][]string{}, }, { name: "fake2", namespace: "", annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "fake1.ext-dna.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "example.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dns.test.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dna.test.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", combineFQDNAndAnnotation: true, }, { title: "gateway rules with annotation", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{{"example.org"}}, }, { name: "fake2", namespace: "", annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{{"example2.org"}}, }, { name: "fake3", namespace: "", annotations: map[string]string{ IstioGatewayIngressSource: "not-real/ingress1", annotations.TargetKey: "1.2.3.4", }, dnsnames: [][]string{{"example3.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example2.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example3.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "gateway rules with hostname annotation", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"1.2.3.4"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "gateway rules with hostname annotation having multiple hostnames", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"1.2.3.4"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com, another-dns-through-hostname.com", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "another-dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "gateway rules with hostname and target annotation", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com", annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, { title: "gateway rules with hostname, target and ingress annotation", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{}, }, }, ingresses: []fakeIngress{ { name: "ingress1", ips: []string{}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ IstioGatewayIngressSource: "ingress1", annotations.HostnameKey: "dns-through-hostname.com", annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{{"example.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, { title: "gateway rules with annotation and custom TTL", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", annotations.TtlKey: "6", }, dnsnames: [][]string{{"example.org"}}, }, { name: "fake2", namespace: "", annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", annotations.TtlKey: "1", }, dnsnames: [][]string{{"example2.org"}}, }, { name: "fake3", namespace: "", annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", annotations.TtlKey: "10s", }, dnsnames: [][]string{{"example3.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"gateway-target.com"}, RecordTTL: endpoint.TTL(6), }, { DNSName: "example2.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"gateway-target.com"}, RecordTTL: endpoint.TTL(1), }, { DNSName: "example3.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"gateway-target.com"}, RecordTTL: endpoint.TTL(10), }, }, }, { title: "template for gateway with annotation", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{}, hostnames: []string{}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{}, }, { name: "fake2", namespace: "", annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{}, }, { name: "fake3", namespace: "", annotations: map[string]string{ annotations.TargetKey: "1.2.3.4", }, dnsnames: [][]string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.ext-dns.test.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake2.ext-dns.test.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "fake3.ext-dns.test.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "Ingress with empty annotation", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{}, hostnames: []string{}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.TargetKey: "", }, dnsnames: [][]string{}, }, }, expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "Gateway with empty ingress annotation", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{}, hostnames: []string{}, }, }, ingresses: []fakeIngress{ { name: "ingress1", ips: []string{}, hostnames: []string{}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ IstioGatewayIngressSource: "", }, dnsnames: [][]string{}, }, }, expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "ignore hostname annotations", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.HostnameKey: "ignore.me", }, dnsnames: [][]string{{"example.org"}}, }, { name: "fake2", namespace: "", annotations: map[string]string{ annotations.HostnameKey: "ignore.me.too", }, dnsnames: [][]string{{"new.org"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, ignoreHostnameAnnotation: true, }, { title: "gateways with wildcard host", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"1.2.3.4"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", dnsnames: [][]string{{"*"}}, }, { name: "fake2", namespace: "", dnsnames: [][]string{{"some-namespace/*"}}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "gateways with wildcard host and hostname annotation", targetNamespace: "", lbServices: []fakeIngressGatewayService{ { ips: []string{"1.2.3.4"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ annotations.HostnameKey: "fake1.dns-through-hostname.com", }, dnsnames: [][]string{{"*"}}, }, { name: "fake2", namespace: "", annotations: map[string]string{ annotations.HostnameKey: "fake2.dns-through-hostname.com", }, dnsnames: [][]string{{"some-namespace/*"}}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "fake1.dns-through-hostname.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, { DNSName: "fake2.dns-through-hostname.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, }, }, }, { title: "gateways with ingress annotation; ingress not found", targetNamespace: "", ingresses: []fakeIngress{ { name: "ingress1", ips: []string{"8.8.8.8"}, }, }, configItems: []fakeGatewayConfig{ { name: "fake1", namespace: "", annotations: map[string]string{ IstioGatewayIngressSource: "ingress2", }, dnsnames: [][]string{{"new.org"}}, }, }, expected: []*endpoint.Endpoint{}, expectError: true, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fake.NewClientset() targetNamespace := ti.targetNamespace for _, lb := range ti.lbServices { service := lb.Service() _, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) } for _, ing := range ti.ingresses { ingress := ing.Ingress() if ingress.Namespace != targetNamespace { targetNamespace = v1.NamespaceAll } _, err := fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(t.Context(), ingress, metav1.CreateOptions{}) require.NoError(t, err) } fakeIstioClient := istiofake.NewSimpleClientset() for _, config := range ti.configItems { gatewayCfg := config.Config() _, err := fakeIstioClient.NetworkingV1beta1().Gateways(ti.targetNamespace).Create(t.Context(), gatewayCfg, metav1.CreateOptions{}) require.NoError(t, err) } gatewaySource, err := NewIstioGatewaySource( t.Context(), fakeKubernetesClient, fakeIstioClient, &Config{ Namespace: targetNamespace, FQDNTemplate: ti.fqdnTemplate, CombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation, IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, AnnotationFilter: ti.annotationFilter, }, ) require.NoError(t, err) res, err := gatewaySource.Endpoints(t.Context()) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } validateEndpoints(t, res, ti.expected) }) } } func TestGatewaySource_GWSelectorMatchServiceSelector(t *testing.T) { tests := []struct { name string selectors map[string]string expected []*endpoint.Endpoint }{ { name: "gw single selector match with single service selector", selectors: map[string]string{ "version": "v1", }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.org", endpoint.RecordTypeA, "10.10.10.255").WithLabel("resource", "gateway/default/fake-gateway"), }, }, { name: "gw selector match all service selectors", selectors: map[string]string{ "app": "demo", "env": "prod", "team": "devops", "version": "v1", "release": "stable", "track": "daily", "tier": "backend", }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.org", endpoint.RecordTypeA, "10.10.10.255").WithLabel("resource", "gateway/default/fake-gateway"), }, }, { name: "gw selector has subset of service selectors", selectors: map[string]string{ "version": "v1", "release": "stable", "tier": "backend", "app": "demo", }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.org", endpoint.RecordTypeA, "10.10.10.255").WithLabel("resource", "gateway/default/fake-gateway"), }, }, } for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { fakeKubeClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "fake-service", Namespace: "default", UID: types.UID(fmt.Sprintf("fake-service-uid-%d", i)), }, Spec: v1.ServiceSpec{ Selector: map[string]string{ "app": "demo", "env": "prod", "team": "devops", "version": "v1", "release": "stable", "track": "daily", "tier": "backend", }, ExternalIPs: []string{"10.10.10.255"}, }, } _, err := fakeKubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) gw := &networkingv1beta1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: "fake-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Servers: []*istionetworking.Server{ { Hosts: []string{"example.org"}, }, }, Selector: tt.selectors, }, } _, err = fakeIstioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{}) require.NoError(t, err) src, err := NewIstioGatewaySource( t.Context(), fakeKubeClient, fakeIstioClient, &Config{}, ) require.NoError(t, err) require.NotNil(t, src) res, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, res, tt.expected) }) } } func TestTransformerInIstioGatewaySource(t *testing.T) { svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "fake-service", Namespace: "default", Labels: map[string]string{ "label1": "value1", "label2": "value2", "label3": "value3", }, Annotations: map[string]string{ "user-annotation": "value", "external-dns.alpha.kubernetes.io/hostname": "test-hostname", "external-dns.alpha.kubernetes.io/random": "value", "other/annotation": "value", }, UID: "someuid", }, Spec: v1.ServiceSpec{ Selector: map[string]string{ "selector": "one", "selector2": "two", "selector3": "three", }, ExternalIPs: []string{"1.2.3.4"}, Ports: []v1.ServicePort{ { Name: "http", Port: 80, TargetPort: intstr.FromInt32(8080), Protocol: v1.ProtocolTCP, }, { Name: "https", Port: 443, TargetPort: intstr.FromInt32(8443), Protocol: v1.ProtocolTCP, }, }, Type: v1.ServiceTypeLoadBalancer, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ {IP: "5.6.7.8", Hostname: "lb.example.com"}, }, }, Conditions: []metav1.Condition{ { Type: "Available", Status: metav1.ConditionTrue, Reason: "MinimumReplicasAvailable", Message: "Service is available", LastTransitionTime: metav1.Now(), }, }, }, } fakeClient := fake.NewClientset() _, err := fakeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) src, err := NewIstioGatewaySource( t.Context(), fakeClient, istiofake.NewSimpleClientset(), &Config{}) require.NoError(t, err) gwSource, ok := src.(*gatewaySource) require.True(t, ok) rService, err := gwSource.serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name) require.NoError(t, err) assert.Equal(t, "fake-service", rService.Name) assert.Empty(t, rService.Labels) assert.Empty(t, rService.Annotations) assert.Empty(t, rService.UID) assert.NotEmpty(t, rService.Status.LoadBalancer) assert.Empty(t, rService.Status.Conditions) assert.Equal(t, map[string]string{ "selector": "one", "selector2": "two", "selector3": "three", }, rService.Spec.Selector) } func TestSingleGatewayMultipleServicesPointingToSameLoadBalancer(t *testing.T) { fakeKubeClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() gw := &networkingv1beta1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: "argocd", Namespace: "argocd", }, Spec: istionetworking.Gateway{ Servers: []*istionetworking.Server{ { Hosts: []string{"example.org"}, Tls: &istionetworking.ServerTLSSettings{ HttpsRedirect: true, }, }, { Hosts: []string{"example.org"}, Tls: &istionetworking.ServerTLSSettings{ ServerCertificate: IstioGatewayIngressSource, Mode: istionetworking.ServerTLSSettings_SIMPLE, }, }, }, Selector: map[string]string{ "istio": "ingressgateway", }, }, } services := []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{ "app": "istio-ingressgateway", "istio": "ingressgateway", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, ClusterIP: "10.118.223.3", ClusterIPs: []string{"10.118.223.3"}, ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyCluster, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, IPFamilyPolicy: testutils.ToPtr(v1.IPFamilyPolicySingleStack), Ports: []v1.ServicePort{ { Name: "http2", Port: 80, Protocol: v1.ProtocolTCP, TargetPort: intstr.FromInt32(8080), NodePort: 30127, }, }, Selector: map[string]string{ "app": "istio-ingressgateway", "istio": "ingressgateway", }, SessionAffinity: v1.ServiceAffinityNone, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ { IP: "34.66.66.77", IPMode: testutils.ToPtr(v1.LoadBalancerIPModeVIP), }, }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgatewayudp", Namespace: "default", Labels: map[string]string{ "app": "istio-ingressgatewayudp", "istio": "ingressgateway", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, ClusterIP: "10.118.220.130", ClusterIPs: []string{"10.118.220.130"}, ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyCluster, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, IPFamilyPolicy: testutils.ToPtr(v1.IPFamilyPolicySingleStack), Ports: []v1.ServicePort{ { Name: "upd-dns", Port: 53, Protocol: v1.ProtocolUDP, TargetPort: intstr.FromInt32(5353), NodePort: 30873, }, }, Selector: map[string]string{ "app": "istio-ingressgatewayudp", "istio": "ingressgateway", }, SessionAffinity: v1.ServiceAffinityNone, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ { IP: "34.66.66.77", IPMode: testutils.ToPtr(v1.LoadBalancerIPModeVIP), }, }, }, }, }, } assert.NotNil(t, services) for _, svc := range services { _, err := fakeKubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) } _, err := fakeIstioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{}) require.NoError(t, err) src, err := NewIstioGatewaySource( t.Context(), fakeKubeClient, fakeIstioClient, &Config{}, ) require.NoError(t, err) require.NotNil(t, src) got, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, got, []*endpoint.Endpoint{ endpoint.NewEndpoint("example.org", endpoint.RecordTypeA, "34.66.66.77").WithLabel(endpoint.ResourceLabelKey, "gateway/argocd/argocd"), }) } // gateway specific helper functions func newTestGatewaySource(loadBalancerList []fakeIngressGatewayService, ingressList []fakeIngress) (*gatewaySource, error) { fakeKubernetesClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() for _, lb := range loadBalancerList { service := lb.Service() _, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(context.Background(), service, metav1.CreateOptions{}) if err != nil { return nil, err } } for _, ing := range ingressList { ingress := ing.Ingress() _, err := fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{}) if err != nil { return nil, err } } src, err := NewIstioGatewaySource( context.TODO(), fakeKubernetesClient, fakeIstioClient, &Config{ FQDNTemplate: "{{.FQDN}}", }, ) if err != nil { return nil, err } gwsrc, ok := src.(*gatewaySource) if !ok { return nil, errors.New("underlying source type was not gateway") } return gwsrc, nil } type fakeIngressGatewayService struct { ips []string hostnames []string namespace string name string selector map[string]string externalIPs []string } func (ig fakeIngressGatewayService) Service() *v1.Service { svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Namespace: ig.namespace, Name: ig.name, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{}, }, }, Spec: v1.ServiceSpec{ Selector: ig.selector, ExternalIPs: ig.externalIPs, }, } for _, ip := range ig.ips { svc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{ IP: ip, }) } for _, hostname := range ig.hostnames { svc.Status.LoadBalancer.Ingress = append(svc.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{ Hostname: hostname, }) } return svc } type fakeGatewayConfig struct { namespace string name string annotations map[string]string dnsnames [][]string selector map[string]string } func (c fakeGatewayConfig) Config() *networkingv1beta1.Gateway { gw := &networkingv1beta1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: c.name, Namespace: c.namespace, Annotations: c.annotations, }, Spec: istionetworking.Gateway{ Servers: nil, Selector: c.selector, }, } var servers []*istionetworking.Server for _, dnsnames := range c.dnsnames { servers = append(servers, &istionetworking.Server{ Hosts: dnsnames, }) } gw.Spec.Servers = servers return gw } ================================================ FILE: source/istio_virtualservice.go ================================================ /* Copyright 2020 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 source import ( "cmp" "context" "fmt" "slices" "strings" "text/template" log "github.com/sirupsen/logrus" v1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" istioclient "istio.io/client-go/pkg/clientset/versioned" istioinformers "istio.io/client-go/pkg/informers/externalversions" networkingv1beta1informer "istio.io/client-go/pkg/informers/externalversions/networking/v1beta1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/labels" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" netinformers "k8s.io/client-go/informers/networking/v1" "k8s.io/client-go/kubernetes" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" ) // IstioMeshGateway is the built in gateway for all sidecars const IstioMeshGateway = "mesh" // virtualServiceSource is an implementation of Source for Istio VirtualService objects. // The implementation uses the spec.hosts values for the hostnames. // Use annotations.TargetKey to explicitly set Endpoint. // // +externaldns:source:name=istio-virtualservice // +externaldns:source:category=Service Mesh // +externaldns:source:description=Creates DNS entries from Istio VirtualService resources // +externaldns:source:resources=VirtualService.networking.istio.io // +externaldns:source:filters=annotation // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true type virtualServiceSource struct { kubeClient kubernetes.Interface istioClient istioclient.Interface namespace string annotationFilter string fqdnTemplate *template.Template combineFQDNAnnotation bool ignoreHostnameAnnotation bool serviceInformer coreinformers.ServiceInformer vServiceInformer networkingv1beta1informer.VirtualServiceInformer gatewayInformer networkingv1beta1informer.GatewayInformer ingressInformer netinformers.IngressInformer } // NewIstioVirtualServiceSource creates a new virtualServiceSource with the given config. func NewIstioVirtualServiceSource( ctx context.Context, kubeClient kubernetes.Interface, istioClient istioclient.Interface, cfg *Config, ) (Source, error) { tmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } // Use shared informers to listen for add/update/delete of services/pods/nodes in the specified namespace. // Set resync period to 0, to prevent processing when nothing has changed informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(cfg.Namespace)) serviceInformer := informerFactory.Core().V1().Services() istioInformerFactory := istioinformers.NewSharedInformerFactoryWithOptions(istioClient, 0, istioinformers.WithNamespace(cfg.Namespace)) virtualServiceInformer := istioInformerFactory.Networking().V1beta1().VirtualServices() gatewayInformer := istioInformerFactory.Networking().V1beta1().Gateways() ingressInformer := informerFactory.Networking().V1().Ingresses() _, _ = ingressInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) // Add default resource event handlers to properly initialize informer. _, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) err = serviceInformer.Informer().SetTransform(informers.TransformerWithOptions[*corev1.Service]( informers.TransformWithSpecSelector(), informers.TransformWithSpecExternalIPs(), informers.TransformWithStatusLoadBalancer(), )) if err != nil { return nil, err } _, _ = virtualServiceInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = gatewayInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) istioInformerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } if err := informers.WaitForCacheSync(ctx, istioInformerFactory); err != nil { return nil, err } return &virtualServiceSource{ kubeClient: kubeClient, istioClient: istioClient, namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, fqdnTemplate: tmpl, combineFQDNAnnotation: cfg.CombineFQDNAndAnnotation, ignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, serviceInformer: serviceInformer, vServiceInformer: virtualServiceInformer, gatewayInformer: gatewayInformer, ingressInformer: ingressInformer, }, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all VirtualService resources in the source's namespace(s). func (sc *virtualServiceSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { virtualServices, err := sc.vServiceInformer.Lister().VirtualServices(sc.namespace).List(labels.Everything()) if err != nil { return nil, err } virtualServices, err = annotations.Filter(virtualServices, sc.annotationFilter) if err != nil { return nil, err } var endpoints []*endpoint.Endpoint log.Debugf("Found %d virtualservice in namespace %s", len(virtualServices), sc.namespace) for _, vService := range virtualServices { if annotations.IsControllerMismatch(vService, types.IstioVirtualService) { continue } gwEndpoints, err := sc.endpointsFromVirtualService(ctx, vService) if err != nil { return nil, err } // apply template if host is missing on VirtualService gwEndpoints, err = fqdn.CombineWithTemplatedEndpoints( gwEndpoints, sc.fqdnTemplate, sc.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(ctx, vService) }, ) if err != nil { return nil, err } if endpoint.HasNoEmptyEndpoints(gwEndpoints, types.IstioVirtualService, vService) { continue } log.Debugf("Endpoints generated from %q '%s/%s.%s': %q", vService.Kind, vService.Namespace, vService.APIVersion, vService.Name, gwEndpoints) endpoints = append(endpoints, gwEndpoints...) } return MergeEndpoints(endpoints), nil } // AddEventHandler adds an event handler that should be triggered if the watched Istio VirtualService changes. func (sc *virtualServiceSource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for Istio VirtualService") _, _ = sc.vServiceInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } func (sc *virtualServiceSource) getGateway(_ context.Context, gatewayStr string, virtualService *v1beta1.VirtualService) (*v1beta1.Gateway, error) { if gatewayStr == "" || gatewayStr == IstioMeshGateway { // This refers to "all sidecars in the mesh"; ignore. return nil, nil } namespace, name, err := ParseIngress(gatewayStr) if err != nil { log.Debugf("Failed parsing gatewayStr %s of VirtualService %s/%s", gatewayStr, virtualService.Namespace, virtualService.Name) return nil, err } namespace = cmp.Or(namespace, virtualService.Namespace) gateway, err := sc.gatewayInformer.Lister().Gateways(namespace).Get(name) if errors.IsNotFound(err) { log.Warnf("VirtualService (%s/%s) references non-existent gateway: %s ", virtualService.Namespace, virtualService.Name, gatewayStr) return gateway, nil } else if err != nil { log.Errorf("Failed retrieving gateway %s referenced by VirtualService %s/%s: %v", gatewayStr, virtualService.Namespace, virtualService.Name, err) return nil, err } if gateway == nil { log.Debugf("Gateway %s referenced by VirtualService %s/%s not found: %v", gatewayStr, virtualService.Namespace, virtualService.Name, err) return gateway, nil } return gateway, nil } func (sc *virtualServiceSource) endpointsFromTemplate(ctx context.Context, virtualService *v1beta1.VirtualService) ([]*endpoint.Endpoint, error) { hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, virtualService) if err != nil { return nil, err } resource := fmt.Sprintf("virtualservice/%s/%s", virtualService.Namespace, virtualService.Name) ttl := annotations.TTLFromAnnotations(virtualService.Annotations, resource) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(virtualService.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { targets, err := sc.targetsFromVirtualService(ctx, virtualService, hostname) if err != nil { return endpoints, err } endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } // append a target to the list of targets unless it's already in the list func appendUnique(targets []string, target string) []string { if slices.Contains(targets, target) { return targets } return append(targets, target) } func (sc *virtualServiceSource) targetsFromVirtualService(ctx context.Context, vService *v1beta1.VirtualService, vsHost string) ([]string, error) { var targets []string // for each host we need to iterate through the gateways because each host might match for only one of the gateways for _, gateway := range vService.Spec.Gateways { gw, err := sc.getGateway(ctx, gateway, vService) if err != nil { return nil, err } if gw == nil { continue } if !virtualServiceBindsToGateway(vService, gw, vsHost) { continue } tgs, err := sc.targetsFromGateway(gw) if err != nil { return targets, err } for _, target := range tgs { targets = appendUnique(targets, target) } } return targets, nil } // endpointsFromVirtualService extracts the endpoints from an Istio VirtualService Config object func (sc *virtualServiceSource) endpointsFromVirtualService(ctx context.Context, vService *v1beta1.VirtualService) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint var err error resource := fmt.Sprintf("virtualservice/%s/%s", vService.Namespace, vService.Name) ttl := annotations.TTLFromAnnotations(vService.Annotations, resource) targetsFromAnnotation := annotations.TargetsFromTargetAnnotation(vService.Annotations) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(vService.Annotations) for _, host := range vService.Spec.Hosts { if host == "" || host == "*" { continue } parts := strings.Split(host, "/") // If the input hostname is of the form my-namespace/foo.bar.com, remove the namespace // before appending it to the list of endpoints to create if len(parts) == 2 { host = parts[1] } targets := targetsFromAnnotation if len(targets) == 0 { targets, err = sc.targetsFromVirtualService(ctx, vService, host) if err != nil { return endpoints, err } } endpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } // Skip endpoints if we do not want entries from annotations if !sc.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(vService.Annotations) for _, hostname := range hostnameList { targets := targetsFromAnnotation if len(targets) == 0 { targets, err = sc.targetsFromVirtualService(ctx, vService, hostname) if err != nil { return endpoints, err } } endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } return endpoints, nil } // checks if the given VirtualService should actually bind to the given gateway // see requirements here: https://istio.io/docs/reference/config/networking/gateway/#Server func virtualServiceBindsToGateway(vService *v1beta1.VirtualService, gateway *v1beta1.Gateway, vsHost string) bool { isValid := false if len(vService.Spec.ExportTo) == 0 { isValid = true } else { for _, ns := range vService.Spec.ExportTo { if ns == "*" || ns == gateway.Namespace || (ns == "." && gateway.Namespace == vService.Namespace) { isValid = true } } } if !isValid { return false } for _, server := range gateway.Spec.Servers { for _, host := range server.Hosts { namespace := "*" parts := strings.Split(host, "/") if len(parts) == 2 { namespace = parts[0] host = parts[1] } else if len(parts) != 1 { log.Debugf("Gateway %s/%s has invalid host %s", gateway.Namespace, gateway.Name, host) continue } if namespace == "*" || namespace == vService.Namespace || (namespace == "." && vService.Namespace == gateway.Namespace) { if host == "*" { return true } suffixMatch := false if strings.HasPrefix(host, "*.") { suffixMatch = true } if host == vsHost || (suffixMatch && strings.HasSuffix(vsHost, host[1:])) { return true } } } } return false } func (sc *virtualServiceSource) targetsFromIngress(ingressStr string, gateway *v1beta1.Gateway) (endpoint.Targets, error) { namespace, name, err := ParseIngress(ingressStr) if err != nil { return nil, fmt.Errorf("failed to parse Ingress annotation on Gateway (%s/%s): %w", gateway.Namespace, gateway.Name, err) } if namespace == "" { namespace = gateway.Namespace } ingress, err := sc.ingressInformer.Lister().Ingresses(namespace).Get(name) if err != nil { log.Error(err) return nil, err } targets := make(endpoint.Targets, 0) for _, lb := range ingress.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } else if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } return targets, nil } func (sc *virtualServiceSource) targetsFromGateway(gateway *v1beta1.Gateway) (endpoint.Targets, error) { targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) > 0 { return targets, nil } ingressStr, ok := gateway.Annotations[IstioGatewayIngressSource] if ok && ingressStr != "" { return sc.targetsFromIngress(ingressStr, gateway) } return EndpointTargetsFromServices(sc.serviceInformer, sc.namespace, gateway.Spec.Selector) } ================================================ FILE: source/istio_virtualservice_fqdn_test.go ================================================ /* 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 source import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" istionetworking "istio.io/api/networking/v1beta1" networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" istiofake "istio.io/client-go/pkg/clientset/versioned/fake" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/endpoint" ) func TestIstioVirtualServiceSourceNewSourceWithFqdn(t *testing.T) { for _, tt := range []struct { title string annotationFilter string fqdnTemplate string expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "valid template with multiple hosts", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", }, } { t.Run(tt.title, func(t *testing.T) { _, err := NewIstioVirtualServiceSource( t.Context(), fake.NewClientset(), istiofake.NewSimpleClientset(), &Config{ Namespace: "", AnnotationFilter: "", FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: false, IgnoreHostnameAnnotation: false, }, ) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func TestIstioVirtualServiceSourceFqdnTemplatingExamples(t *testing.T) { annotations.SetAnnotationPrefix("external-dns.alpha.kubernetes.io/") for _, tt := range []struct { title string virtualServices []*networkingv1beta1.VirtualService gateways []*networkingv1beta1.Gateway services []*v1.Service fqdnTemplate string combineFqdn bool expected []*endpoint.Endpoint }{ { title: "simple templating with virtualservice name", fqdnTemplate: "{{.Name}}.test.com", expected: []*endpoint.Endpoint{ {DNSName: "app.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "my-virtualservice.test.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-virtualservice", Namespace: "default", }, Spec: istionetworking.VirtualService{ Hosts: []string{"app.example.org"}, Gateways: []string{"my-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "1.2.3.4"}}, }, }, }, }, }, { title: "templating with fqdn combine disabled", fqdnTemplate: "{{.Name}}.test.com", expected: []*endpoint.Endpoint{ {DNSName: "app.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, combineFqdn: true, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-virtualservice", Namespace: "default", }, Spec: istionetworking.VirtualService{ Hosts: []string{"app.example.org"}, Gateways: []string{"my-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "1.2.3.4"}}, }, }, }, }, }, { title: "templating with namespace", fqdnTemplate: "{{.Name}}.{{.Namespace}}.cluster.local", expected: []*endpoint.Endpoint{ {DNSName: "api.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, {DNSName: "api-service.production.cluster.local", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, {DNSName: "web.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"::ffff:192.1.56.10"}}, {DNSName: "web-service.staging.cluster.local", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"::ffff:192.1.56.10"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "api-service", Namespace: "production", }, Spec: istionetworking.VirtualService{ Hosts: []string{"api.example.org"}, Gateways: []string{"api-gateway"}, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "web-service", Namespace: "staging", }, Spec: istionetworking.VirtualService{ Hosts: []string{"web.example.org"}, Gateways: []string{"web-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "api-gateway", Namespace: "production", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "web-gateway", Namespace: "staging", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway-staging"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "production", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "5.6.7.8"}}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway-staging", Namespace: "staging", Labels: map[string]string{"istio": "ingressgateway-staging"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway-staging"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "::ffff:192.1.56.10"}}, }, }, }, }, }, { title: "templating with multiple fqdn templates", fqdnTemplate: "{{.Name}}.example.com,{{.Name}}.example.org", expected: []*endpoint.Endpoint{ {DNSName: "multi-host.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, {DNSName: "multi-host.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "multi-host", Namespace: "default", }, Spec: istionetworking.VirtualService{ Gateways: []string{"my-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "10.0.0.1"}}, }, }, }, }, }, { title: "combine FQDN annotation with template", fqdnTemplate: "{{.Name}}.internal.example.com", expected: []*endpoint.Endpoint{ {DNSName: "app.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, {DNSName: "combined-vs.internal.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "combined-vs", Namespace: "default", }, Spec: istionetworking.VirtualService{ Hosts: []string{"app.example.org"}, Gateways: []string{"my-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "172.16.0.1"}}, }, }, }, }, }, { title: "complex templating with labels and hosts", fqdnTemplate: "{{ if .Labels.env }}{{.Name}}.{{.Labels.env}}.ex{{ end }}", expected: []*endpoint.Endpoint{ {DNSName: "labeled-vs.dev.ex", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"172.16.0.1"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "labeled-vs", Namespace: "default", Labels: map[string]string{ "env": "dev", }, }, Spec: istionetworking.VirtualService{ Gateways: []string{"my-gateway"}, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "no-labels", Namespace: "default", }, Spec: istionetworking.VirtualService{ Gateways: []string{"my-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "172.16.0.1"}}, }, }, }, }, }, { title: "templating with cross-namespace gateway reference", fqdnTemplate: "{{.Name}}.{{.Namespace}}.svc.cluster.local", expected: []*endpoint.Endpoint{ {DNSName: "cross-ns.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.example.com"}}, {DNSName: "cross-ns-vs.app-namespace.svc.cluster.local", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.example.com"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "cross-ns-vs", Namespace: "app-namespace", }, Spec: istionetworking.VirtualService{ Hosts: []string{"cross-ns.example.org"}, Gateways: []string{"istio-system/shared-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "shared-gateway", Namespace: "istio-system", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "istio-system", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{Hostname: "lb.example.com"}}, }, }, }, }, }, { title: "virtualservice with multiple hosts in spec", fqdnTemplate: "{{.Name}}.internal.local", expected: []*endpoint.Endpoint{ {DNSName: "app1.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, {DNSName: "app2.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, {DNSName: "app3.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, {DNSName: "multi-host-vs.internal.local", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "multi-host-vs", Namespace: "default", }, Spec: istionetworking.VirtualService{ Hosts: []string{"app1.example.org", "app2.example.org", "app3.example.org"}, Gateways: []string{"my-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{"istio": "ingressgateway"}, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "192.168.1.100"}}, }, }, }, }, }, { title: "virtualservice with no matching gateway (no endpoints from spec)", fqdnTemplate: "{{.Name}}.fallback.local", expected: []*endpoint.Endpoint{ {DNSName: "orphan.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"fallback.local"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "orphan-vs", Namespace: "default", Annotations: map[string]string{ annotations.TargetKey: "fallback.local", }, }, Spec: istionetworking.VirtualService{ Hosts: []string{"orphan.example.org"}, Gateways: []string{"non-existent-gateway"}, }, }, }, }, { title: "templating with annotations expansion", fqdnTemplate: `{{ index .ObjectMeta.Annotations "dns.company.com/subdomain" }}.company.local`, expected: []*endpoint.Endpoint{ {DNSName: "api-v2.company.local", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.100"}}, }, virtualServices: []*networkingv1beta1.VirtualService{ { ObjectMeta: metav1.ObjectMeta{ Name: "annotated-vs", Namespace: "default", Annotations: map[string]string{ "dns.company.com/subdomain": "api-v2", annotations.TargetKey: "10.20.30.40", }, }, Spec: istionetworking.VirtualService{ Gateways: []string{"my-gateway"}, }, }, }, gateways: []*networkingv1beta1.Gateway{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Selector: map[string]string{"istio": "ingressgateway"}, Servers: []*istionetworking.Server{ {Hosts: []string{"*"}}, }, }, }, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio", Namespace: "default", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, Selector: map[string]string{"istio": "ingressgateway"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{{IP: "192.168.1.100"}}, }, }, }, }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() istioClient := istiofake.NewSimpleClientset() for _, svc := range tt.services { _, err := kubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) } for _, gw := range tt.gateways { _, err := istioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{}) require.NoError(t, err) } for _, vs := range tt.virtualServices { _, err := istioClient.NetworkingV1beta1().VirtualServices(vs.Namespace).Create(t.Context(), vs, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewIstioVirtualServiceSource( t.Context(), kubeClient, istioClient, &Config{ Namespace: "", AnnotationFilter: "", FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: !tt.combineFqdn, IgnoreHostnameAnnotation: false, }, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } ================================================ FILE: source/istio_virtualservice_test.go ================================================ /* Copyright 2020 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 source import ( "context" "errors" "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "istio.io/api/meta/v1alpha1" istionetworking "istio.io/api/networking/v1beta1" networkingv1beta1 "istio.io/client-go/pkg/apis/networking/v1beta1" istiofake "istio.io/client-go/pkg/clientset/versioned/fake" v1 "k8s.io/api/core/v1" networkv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) // This is a compile-time validation that istioVirtualServiceSource is a Source. var _ Source = &virtualServiceSource{} type VirtualServiceSuite struct { suite.Suite source Source lbServices []*v1.Service ingresses []*networkv1.Ingress gwconfig *networkingv1beta1.Gateway vsconfig *networkingv1beta1.VirtualService } func (suite *VirtualServiceSuite) SetupTest() { fakeKubernetesClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() var err error suite.lbServices = []*v1.Service{ (fakeIngressGatewayService{ ips: []string{"8.8.8.8"}, hostnames: []string{"v1"}, namespace: "istio-system", name: "istio-gateway1", }).Service(), (fakeIngressGatewayService{ ips: []string{"1.1.1.1"}, hostnames: []string{"v42"}, namespace: "istio-system", name: "istio-gateway2", }).Service(), } for _, service := range suite.lbServices { _, err = fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(context.Background(), service, metav1.CreateOptions{}) suite.NoError(err, "should succeed") } suite.ingresses = []*networkv1.Ingress{ (fakeIngress{ ips: []string{"2.2.2.2"}, hostnames: []string{"v2"}, namespace: "istio-system", name: "istio-ingress", }).Ingress(), (fakeIngress{ ips: []string{"3.3.3.3"}, hostnames: []string{"v62"}, namespace: "istio-system", name: "istio-ingress2", }).Ingress(), } for _, ingress := range suite.ingresses { _, err = fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{}) suite.NoError(err, "should succeed") } suite.gwconfig = (fakeGatewayConfig{ name: "foo-gateway-with-targets", namespace: "istio-system", dnsnames: [][]string{{"*"}}, }).Config() _, err = fakeIstioClient.NetworkingV1beta1().Gateways(suite.gwconfig.Namespace).Create(context.Background(), suite.gwconfig, metav1.CreateOptions{}) suite.NoError(err, "should succeed") suite.vsconfig = (fakeVirtualServiceConfig{ name: "foo-virtualservice", namespace: "istio-other", gateways: []string{"istio-system/foo-gateway-with-targets"}, dnsnames: []string{"foo"}, }).Config() _, err = fakeIstioClient.NetworkingV1beta1().VirtualServices(suite.vsconfig.Namespace).Create(context.Background(), suite.vsconfig, metav1.CreateOptions{}) suite.NoError(err, "should succeed") suite.source, err = NewIstioVirtualServiceSource( context.TODO(), fakeKubernetesClient, fakeIstioClient, &Config{ FQDNTemplate: "{{.Name}}", }, ) suite.NoError(err, "should initialize virtualservice source") } func (suite *VirtualServiceSuite) TestResourceLabelIsSet() { endpoints, err := suite.source.Endpoints(context.Background()) suite.NoError(err, "should succeed") suite.Len(endpoints, 2, "should return the correct number of endpoints") for _, ep := range endpoints { suite.Equal("virtualservice/istio-other/foo-virtualservice", ep.Labels[endpoint.ResourceLabelKey], "should set correct resource label") } } func TestVirtualService(t *testing.T) { t.Parallel() suite.Run(t, new(VirtualServiceSuite)) t.Run("virtualServiceBindsToGateway", testVirtualServiceBindsToGateway) t.Run("endpointsFromVirtualServiceConfig", testEndpointsFromVirtualServiceConfig) t.Run("Endpoints", testVirtualServiceEndpoints) t.Run("gatewaySelectorMatchesService", testGatewaySelectorMatchesService) } func TestNewIstioVirtualServiceSource(t *testing.T) { t.Parallel() for _, ti := range []struct { title string annotationFilter string fqdnTemplate string combineFQDNAndAnnotation bool expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", combineFQDNAndAnnotation: true, }, { title: "non-empty annotation filter label", expectError: false, annotationFilter: "kubernetes.io/gateway.class=nginx", }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() _, err := NewIstioVirtualServiceSource( t.Context(), fake.NewClientset(), istiofake.NewSimpleClientset(), &Config{ FQDNTemplate: ti.fqdnTemplate, CombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation, AnnotationFilter: ti.annotationFilter, }, ) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func testVirtualServiceBindsToGateway(t *testing.T) { for _, ti := range []struct { title string gwconfig fakeGatewayConfig vsconfig fakeVirtualServiceConfig vsHost string expected bool }{ { title: "matching host *", gwconfig: fakeGatewayConfig{ dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{}, vsHost: "foo.bar", expected: true, }, { title: "matching host *.", gwconfig: fakeGatewayConfig{ dnsnames: [][]string{{"*.foo.bar"}}, }, vsconfig: fakeVirtualServiceConfig{}, vsHost: "baz.foo.bar", expected: true, }, { title: "not matching host *.", gwconfig: fakeGatewayConfig{ dnsnames: [][]string{{"*.foo.bar"}}, }, vsconfig: fakeVirtualServiceConfig{}, vsHost: "foo.bar", expected: false, }, { title: "not matching host *.", gwconfig: fakeGatewayConfig{ dnsnames: [][]string{{"*.foo.bar"}}, }, vsconfig: fakeVirtualServiceConfig{}, vsHost: "bazfoo.bar", expected: false, }, { title: "not matching host *.", gwconfig: fakeGatewayConfig{ dnsnames: [][]string{{"*.foo.bar"}}, }, vsconfig: fakeVirtualServiceConfig{}, vsHost: "*foo.bar", expected: false, }, { title: "matching host */*", gwconfig: fakeGatewayConfig{ dnsnames: [][]string{{"*/*"}}, }, vsconfig: fakeVirtualServiceConfig{}, vsHost: "foo.bar", expected: true, }, { title: "matching host /*", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"myns/*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "myns", }, vsHost: "foo.bar", expected: true, }, { title: "matching host ./*", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"./*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "istio-system", }, vsHost: "foo.bar", expected: true, }, { title: "not matching host ./*", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"./*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "myns", }, vsHost: "foo.bar", expected: false, }, { title: "not matching host /*", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"myns/*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "otherns", }, vsHost: "foo.bar", expected: false, }, { title: "not matching host /*", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"myns/*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "otherns", }, vsHost: "foo.bar", expected: false, }, { title: "matching exportTo *", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "otherns", exportTo: "*", }, vsHost: "foo.bar", expected: true, }, { title: "matching exportTo ", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "otherns", exportTo: "istio-system", }, vsHost: "foo.bar", expected: true, }, { title: "not matching exportTo ", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "otherns", exportTo: "myns", }, vsHost: "foo.bar", expected: false, }, { title: "not matching exportTo .", gwconfig: fakeGatewayConfig{ namespace: "istio-system", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ namespace: "otherns", exportTo: ".", }, vsHost: "foo.bar", expected: false, }, } { t.Run(ti.title, func(t *testing.T) { vsconfig := ti.vsconfig.Config() gwconfig := ti.gwconfig.Config() require.Equal(t, ti.expected, virtualServiceBindsToGateway(vsconfig, gwconfig, ti.vsHost)) }) } } func testEndpointsFromVirtualServiceConfig(t *testing.T) { t.Parallel() for _, ti := range []struct { title string lbServices []fakeIngressGatewayService ingresses []fakeIngress gwconfig fakeGatewayConfig vsconfig fakeVirtualServiceConfig expected []*endpoint.Endpoint }{ { title: "one rule.host one lb.hostname", lbServices: []fakeIngressGatewayService{ { hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "one rule.host one lb.IP", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "one rule.host one lb.externalIPs", lbServices: []fakeIngressGatewayService{ { externalIPs: []string{"8.8.8.8"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "one rule.host two lb.IP and two lb.Hostname", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8", "127.0.0.1"}, }, { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com", "alb.com"}, }, }, }, { title: "one rule.host two lb.IP and two lb.Hostname and two lb.externalIPs", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, externalIPs: []string{"1.1.1.1", "2.2.2.2"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "2.2.2.2"}, }, }, }, { title: "no rule.host", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, externalIPs: []string{"1.1.1.1", "2.2.2.2"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{}, }, expected: []*endpoint.Endpoint{}, }, { title: "no rule.gateway", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, externalIPs: []string{"1.1.1.1", "2.2.2.2"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{}, }, { title: "one empty rule.host", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8", "127.0.0.1"}, hostnames: []string{"elb.com", "alb.com"}, externalIPs: []string{"1.1.1.1", "2.2.2.2"}, }, }, gwconfig: fakeGatewayConfig{ dnsnames: [][]string{ {""}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "no targets", lbServices: []fakeIngressGatewayService{{}}, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{}, }, { title: "matching selectors for service and gateway", lbServices: []fakeIngressGatewayService{ { name: "service1", selector: map[string]string{ "app": "myservice", }, hostnames: []string{"elb.com", "alb.com"}, }, { name: "service2", selector: map[string]string{ "app": "otherservice", }, ips: []string{"8.8.8.8", "127.0.0.1"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, selector: map[string]string{ "app": "myservice", }, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com", "alb.com"}, }, }, }, { title: "ingress gateway annotation same namespace", ingresses: []fakeIngress{ { name: "ingress1", hostnames: []string{"alb.com", "elb.com"}, }, { name: "ingress2", ips: []string{"127.0.0.1", "8.8.8.8"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, annotations: map[string]string{ IstioGatewayIngressSource: "ingress1", }, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"alb.com", "elb.com"}, }, }, }, { title: "ingress gateway annotation separate namespace", ingresses: []fakeIngress{ { name: "ingress1", namespace: "ingress", hostnames: []string{"alb.com", "elb.com"}, }, { name: "ingress2", namespace: "ingress", ips: []string{"127.0.0.1", "8.8.8.8"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, annotations: map[string]string{ IstioGatewayIngressSource: "ingress/ingress2", }, }, vsconfig: fakeVirtualServiceConfig{ gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"127.0.0.1", "8.8.8.8"}, }, }, }, { title: "provider-specific annotation is converted to endpoint property", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, }, }, gwconfig: fakeGatewayConfig{ name: "mygw", dnsnames: [][]string{{"*"}}, }, vsconfig: fakeVirtualServiceConfig{ annotations: map[string]string{ annotations.AWSPrefix + "weight": "10", }, gateways: []string{"mygw"}, dnsnames: []string{"foo.bar"}, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() for i := range ti.ingresses { if ti.ingresses[i].namespace == "" { ti.ingresses[i].namespace = "test" } } if ti.gwconfig.namespace == "" { ti.gwconfig.namespace = "test" } if ti.vsconfig.namespace == "" { ti.vsconfig.namespace = "test" } if source, err := newTestVirtualServiceSource(ti.lbServices, ti.ingresses, []fakeGatewayConfig{ti.gwconfig}); err != nil { require.NoError(t, err) } else if endpoints, err := source.endpointsFromVirtualService(t.Context(), ti.vsconfig.Config()); err != nil { require.NoError(t, err) } else { validateEndpoints(t, endpoints, ti.expected) } }) } } func testVirtualServiceEndpoints(t *testing.T) { t.Parallel() namespace := "testing" for _, ti := range []struct { title string targetNamespace string annotationFilter string lbServices []fakeIngressGatewayService ingresses []fakeIngress gwConfigs []fakeGatewayConfig vsConfigs []fakeVirtualServiceConfig expected []*endpoint.Endpoint expectError bool fqdnTemplate string combineFQDNAndAnnotation bool ignoreHostnameAnnotation bool }{ { title: "two simple virtualservices with one gateway each, one ingressgateway loadbalancer service", lbServices: []fakeIngressGatewayService{ { namespace: namespace, ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"example.org"}}, }, { name: "fake2", namespace: namespace, dnsnames: [][]string{{"new.org"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake2"}, dnsnames: []string{"new.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "two simple virtualservices with one gateway each, one ingress", lbServices: []fakeIngressGatewayService{ { namespace: namespace, ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, }, ingresses: []fakeIngress{ { name: "ingress1", namespace: namespace, ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"example.org"}}, annotations: map[string]string{ IstioGatewayIngressSource: "ingress1", }, }, { name: "fake2", namespace: namespace, dnsnames: [][]string{{"new.org"}}, annotations: map[string]string{ IstioGatewayIngressSource: "ingress1", }, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake2"}, dnsnames: []string{"new.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "one virtualservice with two gateways, one ingressgateway loadbalancer service", lbServices: []fakeIngressGatewayService{ { namespace: namespace, ips: []string{"8.8.8.8"}, }, }, gwConfigs: []fakeGatewayConfig{ { name: "gw1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, { name: "gw2", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs", namespace: namespace, gateways: []string{"gw1", "gw2"}, dnsnames: []string{"example.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "one virtualservice with two gateways, one ingressgateway loadbalancer service with externalIPs", lbServices: []fakeIngressGatewayService{ { namespace: namespace, externalIPs: []string{"8.8.8.8"}, }, }, gwConfigs: []fakeGatewayConfig{ { name: "gw1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, { name: "gw2", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs", namespace: namespace, gateways: []string{"gw1", "gw2"}, dnsnames: []string{"example.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "two simple virtualservices on different namespaces with the same target gateway, one ingressgateway loadbalancer service", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, namespace: "istio-system", }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: "istio-system", dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: "testing1", gateways: []string{"istio-system/fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: "testing2", gateways: []string{"istio-system/fake1"}, dnsnames: []string{"new.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "two simple virtualservices with one gateway on different namespaces and a target namespace, one ingressgateway loadbalancer service", targetNamespace: "testing1", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, namespace: "testing1", }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: "testing1", dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: "testing1", gateways: []string{"testing1/fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: "testing2", gateways: []string{"testing1/fake1"}, dnsnames: []string{"new.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "two simple virtualservices with one gateway on different namespaces and a target namespace, one ingressgateway loadbalancer service with externalIPs", targetNamespace: "testing1", lbServices: []fakeIngressGatewayService{ { externalIPs: []string{"8.8.8.8"}, namespace: "testing1", }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: "testing1", dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: "testing1", gateways: []string{"testing1/fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: "testing2", gateways: []string{"testing1/fake1"}, dnsnames: []string{"new.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "two simple virtualservices with one gateway on different namespaces and a target namespace, one ingress", targetNamespace: "testing1", ingresses: []fakeIngress{ { name: "ingress1", ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, namespace: "testing1", }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: "testing1", dnsnames: [][]string{{"*"}}, annotations: map[string]string{ IstioGatewayIngressSource: "ingress1", }, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: "testing1", gateways: []string{"testing1/fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: "testing2", gateways: []string{"testing1/fake1"}, dnsnames: []string{"new.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, }, { title: "valid matching annotation filter expression", annotationFilter: "kubernetes.io/virtualservice.class in (alb, nginx)", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, annotations: map[string]string{ "kubernetes.io/virtualservice.class": "nginx", }, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "valid non-matching annotation filter expression", annotationFilter: "kubernetes.io/gateway.class in (alb, nginx)", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, annotations: map[string]string{ "kubernetes.io/virtualservice.class": "tectonic", }, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "invalid annotation filter expression", annotationFilter: "kubernetes.io/gateway.name in (a b)", expected: []*endpoint.Endpoint{}, expectError: true, }, { title: "gateway ingress annotation; ingress not found", ingresses: []fakeIngress{ { name: "ingress1", namespace: namespace, ips: []string{"8.8.8.8"}, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, annotations: map[string]string{ IstioGatewayIngressSource: "ingress2", }, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, }, expected: []*endpoint.Endpoint{}, expectError: true, }, { title: "our controller type is dns-controller", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, }, }, { title: "different controller types are ignored", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, annotations: map[string]string{ annotations.ControllerKey: "some-other-tool", }, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, }, expected: []*endpoint.Endpoint{}, }, { title: "template for virtualservice if host is missing", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"elb.com"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{""}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "vs1.ext-dns.test.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "vs1.ext-dns.test.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com"}, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "multiple FQDN template hostnames", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{""}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "vs1.ext-dns.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "vs1.ext-dna.test.com", Targets: endpoint.Targets{"8.8.8.8"}, RecordType: endpoint.RecordTypeA, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", }, { title: "multiple FQDN template hostnames with restricted gw.hosts", gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{{"*.org", "*.ext-dns.test.com"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "vs1.ext-dns.test.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "vs2.ext-dns.test.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com, {{.Name}}.ext-dna.test.com", combineFQDNAndAnnotation: true, }, { title: "virtualservice with target annotation", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, annotations: map[string]string{ annotations.TargetKey: "virtualservice-target.com", }, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake1"}, annotations: map[string]string{ annotations.TargetKey: "virtualservice-target.com", }, dnsnames: []string{"example2.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"virtualservice-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example2.org", Targets: endpoint.Targets{"virtualservice-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, { title: "virtualservice; gateway with target annotation", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{"example2.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example2.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, { title: "virtualservice; gateway with target and ingress annotation", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, ingresses: []fakeIngress{ { name: "ingress1", ips: []string{"1.1.1.1"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", IstioGatewayIngressSource: "ingress1", }, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{"example2.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "example2.org", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, }, { title: "virtualservice with hostname annotation", lbServices: []fakeIngressGatewayService{ { ips: []string{"1.2.3.4"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, annotations: map[string]string{ annotations.HostnameKey: "dns-through-hostname.com", }, dnsnames: []string{"example.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, { DNSName: "dns-through-hostname.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "virtualservice with hostname annotation having multiple hostnames, restricted by gw.hosts", lbServices: []fakeIngressGatewayService{ { ips: []string{"1.2.3.4"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*.bar.com"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, annotations: map[string]string{ annotations.HostnameKey: "foo.bar.com, another-dns-through-hostname.com", }, dnsnames: []string{"baz.bar.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo.bar.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, }, { title: "virtualservices with annotation and custom TTL", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, annotations: map[string]string{ annotations.TtlKey: "6", }, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake1"}, annotations: map[string]string{ annotations.TtlKey: "1", }, dnsnames: []string{"example2.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, RecordTTL: endpoint.TTL(6), }, { DNSName: "example2.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, RecordTTL: endpoint.TTL(1), }, }, }, { title: "template for virtualservice; gateway with target annotation", gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{{"*"}}, }, { name: "fake2", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "gateway-target.com", }, dnsnames: [][]string{{"*"}}, }, { name: "fake3", namespace: namespace, annotations: map[string]string{ annotations.TargetKey: "1.2.3.4", }, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, dnsnames: []string{}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake2"}, dnsnames: []string{}, }, { name: "vs3", namespace: namespace, gateways: []string{"fake3"}, dnsnames: []string{}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "vs1.ext-dns.test.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "vs2.ext-dns.test.com", Targets: endpoint.Targets{"gateway-target.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "vs3.ext-dns.test.com", Targets: endpoint.Targets{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, { title: "ignore hostname annotations", lbServices: []fakeIngressGatewayService{ { ips: []string{"8.8.8.8"}, hostnames: []string{"lb.com"}, namespace: namespace, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: namespace, dnsnames: [][]string{{"*"}}, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: namespace, gateways: []string{"fake1"}, annotations: map[string]string{ annotations.HostnameKey: "ignore.me", }, dnsnames: []string{"example.org"}, }, { name: "vs2", namespace: namespace, gateways: []string{"fake1"}, annotations: map[string]string{ annotations.HostnameKey: "ignore.me.too", }, dnsnames: []string{"new.org"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"8.8.8.8"}, }, { DNSName: "new.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.com"}, }, }, ignoreHostnameAnnotation: true, }, { title: "complex setup with multiple gateways and multiple vs.hosts only matching some of the gateway", lbServices: []fakeIngressGatewayService{ { name: "svc1", selector: map[string]string{ "app": "igw1", }, hostnames: []string{"target1.com"}, namespace: "istio-system", }, { name: "svc2", selector: map[string]string{ "app": "igw2", }, hostnames: []string{"target2.com"}, namespace: "testing1", }, { name: "svc3", selector: map[string]string{ "app": "igw3", }, hostnames: []string{"target3.com"}, namespace: "testing2", }, }, ingresses: []fakeIngress{ { name: "ingress1", namespace: "testing1", hostnames: []string{"target4.com"}, }, { name: "ingress3", namespace: "testing3", hostnames: []string{"target5.com"}, }, }, gwConfigs: []fakeGatewayConfig{ { name: "fake1", namespace: "istio-system", dnsnames: [][]string{{"*"}}, selector: map[string]string{ "app": "igw1", }, }, { name: "fake2", namespace: "testing1", dnsnames: [][]string{{"*.baz.com"}, {"*.bar.com"}}, selector: map[string]string{ "app": "igw2", }, }, { name: "fake3", namespace: "testing2", dnsnames: [][]string{{"*.bax.com", "*.bar.com"}}, selector: map[string]string{ "app": "igw3", }, }, { name: "fake4", namespace: "testing3", dnsnames: [][]string{{"*.bax.com", "*.bar.com"}}, selector: map[string]string{ "app": "igw4", }, annotations: map[string]string{ IstioGatewayIngressSource: "testing1/ingress1", }, }, }, vsConfigs: []fakeVirtualServiceConfig{ { name: "vs1", namespace: "testing3", gateways: []string{"istio-system/fake1", "testing1/fake2"}, dnsnames: []string{"somedomain.com", "foo.bar.com"}, }, { name: "vs2", namespace: "testing2", gateways: []string{"testing1/fake2", "fake3"}, dnsnames: []string{"hello.bar.com", "hello.bax.com", "hello.bak.com"}, }, { name: "vs3", namespace: "testing1", gateways: []string{"istio-system/fake1", "testing2/fake3"}, dnsnames: []string{"world.bax.com", "world.bak.com"}, }, { name: "vs4", namespace: "testing1", gateways: []string{"istio-system/fake1", "testing3/fake4"}, dnsnames: []string{"foo.bax.com", "foo.bak.com"}, }, }, expected: []*endpoint.Endpoint{ { DNSName: "somedomain.com", Targets: endpoint.Targets{"target1.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "foo.bar.com", Targets: endpoint.Targets{"target1.com", "target2.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "hello.bar.com", Targets: endpoint.Targets{"target2.com", "target3.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "hello.bax.com", Targets: endpoint.Targets{"target3.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "world.bak.com", Targets: endpoint.Targets{"target1.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "world.bax.com", Targets: endpoint.Targets{"target1.com", "target3.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "foo.bak.com", Targets: endpoint.Targets{"target1.com"}, RecordType: endpoint.RecordTypeCNAME, }, { DNSName: "foo.bax.com", Targets: endpoint.Targets{"target1.com", "target4.com"}, RecordType: endpoint.RecordTypeCNAME, }, }, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() var gateways []*networkingv1beta1.Gateway var virtualservices []*networkingv1beta1.VirtualService for _, gwItem := range ti.gwConfigs { gateways = append(gateways, gwItem.Config()) } for _, vsItem := range ti.vsConfigs { virtualservices = append(virtualservices, vsItem.Config()) } fakeKubernetesClient := fake.NewClientset() for _, lb := range ti.lbServices { service := lb.Service() _, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) } for _, ing := range ti.ingresses { ingress := ing.Ingress() _, err := fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(t.Context(), ingress, metav1.CreateOptions{}) require.NoError(t, err) } fakeIstioClient := istiofake.NewSimpleClientset() for _, gateway := range gateways { _, err := fakeIstioClient.NetworkingV1beta1().Gateways(gateway.Namespace).Create(t.Context(), gateway, metav1.CreateOptions{}) require.NoError(t, err) } for _, vService := range virtualservices { _, err := fakeIstioClient.NetworkingV1beta1().VirtualServices(vService.Namespace).Create(t.Context(), vService, metav1.CreateOptions{}) require.NoError(t, err) } virtualServiceSource, err := NewIstioVirtualServiceSource( t.Context(), fakeKubernetesClient, fakeIstioClient, &Config{ Namespace: ti.targetNamespace, AnnotationFilter: ti.annotationFilter, FQDNTemplate: ti.fqdnTemplate, CombineFQDNAndAnnotation: ti.combineFQDNAndAnnotation, IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, }, ) require.NoError(t, err) res, err := virtualServiceSource.Endpoints(t.Context()) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } validateEndpoints(t, res, ti.expected) }) } } func testGatewaySelectorMatchesService(t *testing.T) { for _, ti := range []struct { title string gwSelector map[string]string lbSelector map[string]string expected bool }{ { title: "gw selector matches lb selector", gwSelector: map[string]string{"istio": "ingressgateway"}, lbSelector: map[string]string{"istio": "ingressgateway"}, expected: true, }, { title: "gw selector matches lb selector partially", gwSelector: map[string]string{"istio": "ingressgateway"}, lbSelector: map[string]string{"release": "istio", "istio": "ingressgateway"}, expected: true, }, { title: "gw selector does not match lb selector", gwSelector: map[string]string{"app": "mytest"}, lbSelector: map[string]string{"istio": "ingressgateway"}, expected: false, }, } { t.Run(ti.title, func(t *testing.T) { require.Equal(t, ti.expected, MatchesServiceSelector(ti.gwSelector, ti.lbSelector)) }) } } func newTestVirtualServiceSource(loadBalancerList []fakeIngressGatewayService, ingressList []fakeIngress, gwList []fakeGatewayConfig) (*virtualServiceSource, error) { fakeKubernetesClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() for _, lb := range loadBalancerList { service := lb.Service() _, err := fakeKubernetesClient.CoreV1().Services(service.Namespace).Create(context.Background(), service, metav1.CreateOptions{}) if err != nil { return nil, err } } for _, ing := range ingressList { ingress := ing.Ingress() _, err := fakeKubernetesClient.NetworkingV1().Ingresses(ingress.Namespace).Create(context.Background(), ingress, metav1.CreateOptions{}) if err != nil { return nil, err } } for _, gw := range gwList { gwObj := gw.Config() // use create instead of add // https://github.com/kubernetes/client-go/blob/92512ee2b8cf6696e9909245624175b7f0c971d9/testing/fixture.go#LL336C3-L336C52 _, err := fakeIstioClient.NetworkingV1beta1().Gateways(gw.namespace).Create(context.Background(), gwObj, metav1.CreateOptions{}) if err != nil { return nil, err } } src, err := NewIstioVirtualServiceSource( context.TODO(), fakeKubernetesClient, fakeIstioClient, &Config{ FQDNTemplate: "{{ .Name }}", }, ) if err != nil { return nil, err } vssrc, ok := src.(*virtualServiceSource) if !ok { return nil, errors.New("underlying source type was not virtualservice") } return vssrc, nil } type fakeVirtualServiceConfig struct { namespace string name string gateways []string annotations map[string]string dnsnames []string exportTo string } func (c fakeVirtualServiceConfig) Config() *networkingv1beta1.VirtualService { vs := istionetworking.VirtualService{ Gateways: c.gateways, Hosts: c.dnsnames, } if c.exportTo != "" { vs.ExportTo = []string{c.exportTo} } return &networkingv1beta1.VirtualService{ ObjectMeta: metav1.ObjectMeta{ Name: c.name, Namespace: c.namespace, Annotations: c.annotations, }, Spec: *vs.DeepCopy(), } } func TestVirtualServiceSourceGetGateway(t *testing.T) { type fields struct { virtualServiceSource *virtualServiceSource } type args struct { ctx context.Context gatewayStr string virtualService *networkingv1beta1.VirtualService } tests := []struct { name string fields fields args args want *networkingv1beta1.Gateway expectedErrStr string }{ {name: "EmptyGateway", fields: fields{ virtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, nil); return vs }(), }, args: args{ ctx: t.Context(), gatewayStr: "", virtualService: nil, }, want: nil, expectedErrStr: ""}, {name: "MeshGateway", fields: fields{ virtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, nil); return vs }(), }, args: args{ ctx: t.Context(), gatewayStr: IstioMeshGateway, virtualService: nil, }, want: nil, expectedErrStr: ""}, {name: "MissingGateway", fields: fields{ virtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, nil); return vs }(), }, args: args{ ctx: t.Context(), gatewayStr: "doesnt/exist", virtualService: &networkingv1beta1.VirtualService{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{Name: "exist", Namespace: "doesnt"}, Spec: istionetworking.VirtualService{}, Status: v1alpha1.IstioStatus{}, }, }, want: nil, expectedErrStr: ""}, {name: "InvalidGatewayStr", fields: fields{ virtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, nil); return vs }(), }, args: args{ ctx: t.Context(), gatewayStr: "1/2/3/", virtualService: &networkingv1beta1.VirtualService{}, }, want: nil, expectedErrStr: "invalid ingress name (name or namespace/name) found \"1/2/3/\""}, {name: "ExistingGateway", fields: fields{ virtualServiceSource: func() *virtualServiceSource { vs, _ := newTestVirtualServiceSource(nil, nil, []fakeGatewayConfig{{ namespace: "bar", name: "foo", }}) return vs }(), }, args: args{ ctx: t.Context(), gatewayStr: "bar/foo", virtualService: &networkingv1beta1.VirtualService{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}, Spec: istionetworking.VirtualService{}, Status: v1alpha1.IstioStatus{}, }, }, want: &networkingv1beta1.Gateway{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}, Spec: istionetworking.Gateway{}, Status: v1alpha1.IstioStatus{}, }, expectedErrStr: ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.fields.virtualServiceSource.getGateway(tt.args.ctx, tt.args.gatewayStr, tt.args.virtualService) if tt.expectedErrStr != "" { assert.EqualError(t, err, tt.expectedErrStr, "getGateway(%v, %v, %v)", tt.args.ctx, tt.args.gatewayStr, tt.args.virtualService) return } else { require.NoError(t, err) } if tt.want != nil && got != nil { tt.want.Spec.ProtoReflect() tt.want.Status.ProtoReflect() assert.Equalf(t, tt.want, got, "getGateway(%v, %v, %v)", tt.args.ctx, tt.args.gatewayStr, tt.args.virtualService) } else { assert.Equalf(t, tt.want, got, "getGateway(%v, %v, %v)", tt.args.ctx, tt.args.gatewayStr, tt.args.virtualService) } }) } } func TestIstioVirtualServiceSource_GWServiceSelectorMatchServiceSelector(t *testing.T) { tests := []struct { name string selectors map[string]string expected []*endpoint.Endpoint }{ { name: "gw single selector match with single service selector", selectors: map[string]string{ "version": "v1", }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.org", endpoint.RecordTypeA, "10.10.10.255").WithLabel("resource", "virtualservice/default/fake-vservice"), }, }, { name: "gw selector match all service selectors", selectors: map[string]string{ "app": "demo", "env": "prod", "team": "devops", "version": "v1", "release": "stable", "track": "daily", "tier": "backend", }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.org", endpoint.RecordTypeA, "10.10.10.255").WithLabel("resource", "virtualservice/default/fake-vservice"), }, }, { name: "gw selector has subset of service selectors", selectors: map[string]string{ "version": "v1", "release": "stable", "tier": "backend", "app": "demo", }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.org", endpoint.RecordTypeA, "10.10.10.255").WithLabel("resource", "virtualservice/default/fake-vservice"), }, }, } for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { fakeKubeClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "fake-service", Namespace: "default", UID: types.UID(fmt.Sprintf("fake-service-uid-%d", i)), }, Spec: v1.ServiceSpec{ Selector: map[string]string{ "app": "demo", "env": "prod", "team": "devops", "version": "v1", "release": "stable", "track": "daily", "tier": "backend", }, ExternalIPs: []string{"10.10.10.255"}, }, } _, err := fakeKubeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) gw := &networkingv1beta1.Gateway{ ObjectMeta: metav1.ObjectMeta{ Name: "fake-gateway", Namespace: "default", }, Spec: istionetworking.Gateway{ Servers: []*istionetworking.Server{ { Hosts: []string{"example.org"}, }, }, Selector: tt.selectors, }, } _, err = fakeIstioClient.NetworkingV1beta1().Gateways(gw.Namespace).Create(t.Context(), gw, metav1.CreateOptions{}) require.NoError(t, err) gwService := &networkingv1beta1.VirtualService{ ObjectMeta: metav1.ObjectMeta{Name: "fake-vservice", Namespace: "default"}, Spec: istionetworking.VirtualService{ Gateways: []string{gw.Namespace + "/" + gw.Name}, Hosts: []string{"example.org"}, ExportTo: []string{"*"}, }, } _, err = fakeIstioClient.NetworkingV1beta1().VirtualServices(gwService.Namespace).Create(t.Context(), gwService, metav1.CreateOptions{}) require.NoError(t, err) src, err := NewIstioVirtualServiceSource( t.Context(), fakeKubeClient, fakeIstioClient, &Config{}, ) require.NoError(t, err) require.NotNil(t, src) res, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, res, tt.expected) }) } } func TestTransformerInIstioGatewayVirtualServiceSource(t *testing.T) { svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "fake-service", Namespace: "default", Labels: map[string]string{ "label1": "value1", "label2": "value2", "label3": "value3", }, Annotations: map[string]string{ "user-annotation": "value", "external-dns.alpha.kubernetes.io/hostname": "test-hostname", "external-dns.alpha.kubernetes.io/random": "value", "other/annotation": "value", }, UID: "someuid", }, Spec: v1.ServiceSpec{ Selector: map[string]string{ "selector": "one", "selector2": "two", "selector3": "three", }, ExternalIPs: []string{"1.2.3.4"}, Ports: []v1.ServicePort{ { Name: "http", Port: 80, TargetPort: intstr.FromInt32(8080), Protocol: v1.ProtocolTCP, }, { Name: "https", Port: 443, TargetPort: intstr.FromInt32(8443), Protocol: v1.ProtocolTCP, }, }, Type: v1.ServiceTypeLoadBalancer, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ {IP: "5.6.7.8", Hostname: "lb.example.com"}, }, }, Conditions: []metav1.Condition{ { Type: "Available", Status: metav1.ConditionTrue, Reason: "MinimumReplicasAvailable", Message: "Service is available", LastTransitionTime: metav1.Now(), }, }, }, } fakeClient := fake.NewClientset() _, err := fakeClient.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) src, err := NewIstioVirtualServiceSource( t.Context(), fakeClient, istiofake.NewSimpleClientset(), &Config{}) require.NoError(t, err) gwSource, ok := src.(*virtualServiceSource) require.True(t, ok) rService, err := gwSource.serviceInformer.Lister().Services(svc.Namespace).Get(svc.Name) require.NoError(t, err) assert.Equal(t, svc.Name, rService.Name) assert.Empty(t, rService.Labels) assert.Empty(t, rService.Annotations) assert.Empty(t, rService.UID) assert.NotEmpty(t, rService.Status.LoadBalancer) assert.Empty(t, rService.Status.Conditions) assert.Equal(t, map[string]string{ "selector": "one", "selector2": "two", "selector3": "three", }, rService.Spec.Selector) } ================================================ FILE: source/kong_tcpingress.go ================================================ /* Copyright 2021 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 source import ( "context" "errors" "fmt" log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/informers" ) var kongGroupdVersionResource = schema.GroupVersionResource{ Group: "configuration.konghq.com", Version: "v1beta1", Resource: "tcpingresses", } // kongTCPIngressSource is an implementation of Source for Kong TCPIngress objects. // // +externaldns:source:name=kong-tcpingress // +externaldns:source:category=Ingress Controllers // +externaldns:source:description=Creates DNS entries from Kong TCPIngress resources // +externaldns:source:resources=TCPIngress.configuration.konghq.com // +externaldns:source:filters=annotation // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=false // +externaldns:source:provider-specific=true type kongTCPIngressSource struct { annotationFilter string ignoreHostnameAnnotation bool dynamicKubeClient dynamic.Interface kongTCPIngressInformer kubeinformers.GenericInformer kubeClient kubernetes.Interface namespace string unstructuredConverter *unstructuredConverter } // NewKongTCPIngressSource creates a new kongTCPIngressSource with the given config. func NewKongTCPIngressSource( ctx context.Context, dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, cfg *Config, ) (Source, error) { // Use shared informer to listen for add/update/delete of Host in the specified namespace. // Set resync period to 0, to prevent processing when nothing has changed. informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil) kongTCPIngressInformer := informerFactory.ForResource(kongGroupdVersionResource) // Add default resource event handlers to properly initialize informer. _, _ = kongTCPIngressInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil { return nil, err } uc, err := newKongUnstructuredConverter() if err != nil { return nil, fmt.Errorf("failed to setup Unstructured Converter: %w", err) } return &kongTCPIngressSource{ annotationFilter: cfg.AnnotationFilter, ignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, dynamicKubeClient: dynamicKubeClient, kongTCPIngressInformer: kongTCPIngressInformer, kubeClient: kubeClient, namespace: cfg.Namespace, unstructuredConverter: uc, }, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all TCPIngresses in the source's namespace(s). func (sc *kongTCPIngressSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { tis, err := sc.kongTCPIngressInformer.Lister().ByNamespace(sc.namespace).List(labels.Everything()) if err != nil { return nil, err } var tcpIngresses []*TCPIngress for _, tcpIngressObj := range tis { unstructuredHost, ok := tcpIngressObj.(*unstructured.Unstructured) if !ok { return nil, errors.New("could not convert") } tcpIngress := &TCPIngress{} err := sc.unstructuredConverter.scheme.Convert(unstructuredHost, tcpIngress, nil) if err != nil { return nil, err } tcpIngresses = append(tcpIngresses, tcpIngress) } tcpIngresses, err = annotations.Filter(tcpIngresses, sc.annotationFilter) if err != nil { return nil, fmt.Errorf("failed to filter TCPIngresses: %w", err) } var endpoints []*endpoint.Endpoint for _, tcpIngress := range tcpIngresses { targets := annotations.TargetsFromTargetAnnotation(tcpIngress.Annotations) if len(targets) == 0 { for _, lb := range tcpIngress.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } } fullname := fmt.Sprintf("%s/%s", tcpIngress.Namespace, tcpIngress.Name) ingressEndpoints := sc.endpointsFromTCPIngress(tcpIngress, targets) if endpoint.HasNoEmptyEndpoints(ingressEndpoints, types.KongTCPIngress, tcpIngress) { continue } log.Debugf("Endpoints generated from TCPIngress: %s: %v", fullname, ingressEndpoints) endpoints = append(endpoints, ingressEndpoints...) } return MergeEndpoints(endpoints), nil } // endpointsFromTCPIngress extracts the endpoints from a TCPIngress object func (sc *kongTCPIngressSource) endpointsFromTCPIngress(tcpIngress *TCPIngress, targets endpoint.Targets) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint resource := fmt.Sprintf("tcpingress/%s/%s", tcpIngress.Namespace, tcpIngress.Name) ttl := annotations.TTLFromAnnotations(tcpIngress.Annotations, resource) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(tcpIngress.Annotations) if !sc.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(tcpIngress.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } if tcpIngress.Spec.Rules != nil { for _, rule := range tcpIngress.Spec.Rules { if rule.Host != "" { endpoints = append(endpoints, endpoint.EndpointsForHostname(rule.Host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } } return endpoints } func (sc *kongTCPIngressSource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for TCPIngress") // Right now there is no way to remove event handler from informer, see: // https://github.com/kubernetes/kubernetes/issues/79610 _, _ = sc.kongTCPIngressInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } // newUnstructuredConverter returns a new unstructuredConverter initialized func newKongUnstructuredConverter() (*unstructuredConverter, error) { uc := &unstructuredConverter{ scheme: runtime.NewScheme(), } // Add the core types we need uc.scheme.AddKnownTypes(kongGroupdVersionResource.GroupVersion(), &TCPIngress{}, &TCPIngressList{}) if err := scheme.AddToScheme(uc.scheme); err != nil { return nil, err } return uc, nil } // Kong types based on https://github.com/Kong/kubernetes-ingress-controller/blob/v1.2.0/pkg/apis/configuration/v1beta1/types.go to facilitate testing // When trying to import them from the Kong repo as a dependency it required upgrading the k8s.io/client-go and k8s.io/apimachinery which seemed // cause several changes in how the mock clients were working that resulted in a bunch of failures in other tests // If that is dealt with at some point the below can be removed and replaced with an actual import type TCPIngress struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata"` Spec tcpIngressSpec `json:"spec"` Status tcpIngressStatus `json:"status"` } type TCPIngressList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata"` Items []TCPIngress `json:"items"` } type tcpIngressSpec struct { Rules []tcpIngressRule `json:"rules,omitempty"` TLS []tcpIngressTLS `json:"tls,omitempty"` } type tcpIngressTLS struct { Hosts []string `json:"hosts,omitempty"` SecretName string `json:"secretName,omitempty"` } type tcpIngressStatus struct { LoadBalancer corev1.LoadBalancerStatus `json:"loadBalancer"` } type tcpIngressRule struct { Host string `json:"host,omitempty"` Port int `json:"port,omitempty"` Backend tcpIngressBackend `json:"backend"` } type tcpIngressBackend struct { ServiceName string `json:"serviceName"` ServicePort int `json:"servicePort"` } func (in *tcpIngressBackend) DeepCopyInto(out *tcpIngressBackend) { *out = *in } func (in *tcpIngressBackend) DeepCopy() *tcpIngressBackend { if in == nil { return nil } out := new(tcpIngressBackend) in.DeepCopyInto(out) return out } func (in *tcpIngressRule) DeepCopyInto(out *tcpIngressRule) { *out = *in out.Backend = in.Backend } func (in *tcpIngressRule) DeepCopy() *tcpIngressRule { if in == nil { return nil } out := new(tcpIngressRule) in.DeepCopyInto(out) return out } func (in *tcpIngressSpec) DeepCopyInto(out *tcpIngressSpec) { *out = *in if in.Rules != nil { in, out := &in.Rules, &out.Rules *out = make([]tcpIngressRule, len(*in)) copy(*out, *in) } if in.TLS != nil { in, out := &in.TLS, &out.TLS *out = make([]tcpIngressTLS, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } func (in *tcpIngressSpec) DeepCopy() *tcpIngressSpec { if in == nil { return nil } out := new(tcpIngressSpec) in.DeepCopyInto(out) return out } func (in *tcpIngressStatus) DeepCopyInto(out *tcpIngressStatus) { *out = *in in.LoadBalancer.DeepCopyInto(&out.LoadBalancer) } func (in *tcpIngressStatus) DeepCopy() *tcpIngressStatus { if in == nil { return nil } out := new(tcpIngressStatus) in.DeepCopyInto(out) return out } func (in *tcpIngressTLS) DeepCopyInto(out *tcpIngressTLS) { *out = *in if in.Hosts != nil { in, out := &in.Hosts, &out.Hosts *out = make([]string, len(*in)) copy(*out, *in) } } func (in *tcpIngressTLS) DeepCopy() *tcpIngressTLS { if in == nil { return nil } out := new(tcpIngressTLS) in.DeepCopyInto(out) return out } func (in *TCPIngress) DeepCopyInto(out *TCPIngress) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } func (in *TCPIngress) DeepCopy() *TCPIngress { if in == nil { return nil } out := new(TCPIngress) in.DeepCopyInto(out) return out } func (in *TCPIngress) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } return nil } func (in *TCPIngressList) DeepCopyInto(out *TCPIngressList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items *out = make([]TCPIngress, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } func (in *TCPIngressList) DeepCopy() *TCPIngressList { if in == nil { return nil } out := new(TCPIngressList) in.DeepCopyInto(out) return out } func (in *TCPIngressList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } return nil } ================================================ FILE: source/kong_tcpingress_test.go ================================================ /* Copyright 2021 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 source import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" fakeDynamic "k8s.io/client-go/dynamic/fake" fakeKube "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) // This is a compile-time validation that kongTCPIngressSource is a Source. var _ Source = &kongTCPIngressSource{} const defaultKongNamespace = "kong" func TestKongTCPIngressEndpoints(t *testing.T) { t.Parallel() for _, ti := range []struct { title string tcpProxy TCPIngress ignoreHostnameAnnotation bool expected []*endpoint.Endpoint }{ { title: "TCPIngress with hostname annotation", tcpProxy: TCPIngress{ TypeMeta: metav1.TypeMeta{ APIVersion: kongGroupdVersionResource.GroupVersion().String(), Kind: "TCPIngress", }, ObjectMeta: metav1.ObjectMeta{ Name: "tcp-ingress-annotation", Namespace: defaultKongNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "kubernetes.io/ingress.class": "kong", }, }, Spec: tcpIngressSpec{ Rules: []tcpIngressRule{ { Port: 30000, }, { Port: 30001, }, }, }, Status: tcpIngressStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { Hostname: "a691234567a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com", }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"a691234567a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "TCPIngress using SNI", tcpProxy: TCPIngress{ TypeMeta: metav1.TypeMeta{ APIVersion: kongGroupdVersionResource.GroupVersion().String(), Kind: "TCPIngress", }, ObjectMeta: metav1.ObjectMeta{ Name: "tcp-ingress-sni", Namespace: defaultKongNamespace, Annotations: map[string]string{ "kubernetes.io/ingress.class": "kong", }, }, Spec: tcpIngressSpec{ Rules: []tcpIngressRule{ { Port: 30002, Host: "b.example.com", }, { Port: 30003, Host: "c.example.com", }, }, }, Status: tcpIngressStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { Hostname: "a123456769a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com", }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "b.example.com", Targets: []string{"a123456769a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-sni", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "c.example.com", Targets: []string{"a123456769a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-sni", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "TCPIngress with hostname annotation and using SNI", tcpProxy: TCPIngress{ TypeMeta: metav1.TypeMeta{ APIVersion: kongGroupdVersionResource.GroupVersion().String(), Kind: "TCPIngress", }, ObjectMeta: metav1.ObjectMeta{ Name: "tcp-ingress-both", Namespace: defaultKongNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "d.example.com", "kubernetes.io/ingress.class": "kong", }, }, Spec: tcpIngressSpec{ Rules: []tcpIngressRule{ { Port: 30004, Host: "e.example.com", }, { Port: 30005, Host: "f.example.com", }, }, }, Status: tcpIngressStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { Hostname: "a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com", }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "d.example.com", Targets: []string{"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-both", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "e.example.com", Targets: []string{"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-both", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "f.example.com", Targets: []string{"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-both", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "TCPIngress ignoring hostname annotation", tcpProxy: TCPIngress{ TypeMeta: metav1.TypeMeta{ APIVersion: kongGroupdVersionResource.GroupVersion().String(), Kind: "TCPIngress", }, ObjectMeta: metav1.ObjectMeta{ Name: "tcp-ingress-both", Namespace: defaultKongNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "d.example.com", "kubernetes.io/ingress.class": "kong", }, }, Spec: tcpIngressSpec{ Rules: []tcpIngressRule{ { Port: 30004, Host: "e.example.com", }, { Port: 30005, Host: "f.example.com", }, }, }, Status: tcpIngressStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { Hostname: "a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com", }, }, }, }, }, ignoreHostnameAnnotation: true, expected: []*endpoint.Endpoint{ { DNSName: "e.example.com", Targets: []string{"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-both", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "f.example.com", Targets: []string{"a12e71861a4303f063456769a314a3bd-1291189659.us-east-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-both", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "TCPIngress with target annotation", tcpProxy: TCPIngress{ TypeMeta: metav1.TypeMeta{ APIVersion: kongGroupdVersionResource.GroupVersion().String(), Kind: "TCPIngress", }, ObjectMeta: metav1.ObjectMeta{ Name: "tcp-ingress-sni", Namespace: defaultKongNamespace, Annotations: map[string]string{ "kubernetes.io/ingress.class": "kong", "external-dns.alpha.kubernetes.io/target": "203.2.45.7", }, }, Spec: tcpIngressSpec{ Rules: []tcpIngressRule{ { Port: 30002, Host: "b.example.com", }, { Port: 30003, Host: "c.example.com", }, }, }, Status: tcpIngressStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { Hostname: "a123456769a314e71861a4303f06a3bd-1291189659.us-east-1.elb.amazonaws.com", }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "b.example.com", Targets: []string{"203.2.45.7"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-sni", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "c.example.com", Targets: []string{"203.2.45.7"}, RecordType: endpoint.RecordTypeA, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-sni", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "TCPIngress with provider-specific annotation", tcpProxy: TCPIngress{ TypeMeta: metav1.TypeMeta{ APIVersion: kongGroupdVersionResource.GroupVersion().String(), Kind: "TCPIngress", }, ObjectMeta: metav1.ObjectMeta{ Name: "tcp-ingress-provider-specific", Namespace: defaultKongNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "kubernetes.io/ingress.class": "kong", annotations.AWSPrefix + "weight": "10", }, }, Status: tcpIngressStatus{ LoadBalancer: corev1.LoadBalancerStatus{ Ingress: []corev1.LoadBalancerIngress{ { IP: "1.2.3.4", }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"1.2.3.4"}, RecordType: endpoint.RecordTypeA, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "tcpingress/kong/tcp-ingress-provider-specific", }, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(kongGroupdVersionResource.GroupVersion(), &TCPIngress{}, &TCPIngressList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) tcpi := unstructured.Unstructured{} tcpIngressAsJSON, err := json.Marshal(ti.tcpProxy) assert.NoError(t, err) assert.NoError(t, tcpi.UnmarshalJSON(tcpIngressAsJSON)) // Create proxy resources _, err = fakeDynamicClient.Resource(kongGroupdVersionResource).Namespace(defaultKongNamespace).Create(t.Context(), &tcpi, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewKongTCPIngressSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultKongNamespace, AnnotationFilter: "kubernetes.io/ingress.class=kong", IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, }) assert.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(kongGroupdVersionResource).Namespace(defaultKongNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } ================================================ FILE: source/main_test.go ================================================ /* Copyright 2026 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 source import ( "os" "testing" "k8s.io/client-go/features" ) func TestMain(m *testing.M) { // Disable WatchListClient to prevent 10s timeouts when using fake clients. // Since client-go v0.35, WatchListClient is enabled by default, but fake // clients don't emit the required bookmark events, causing reflectors to // stall for 10 seconds before falling back to the legacy list/watch path. // Only disable if it is actually on; pre-v0.35 client-go defaults it to // false so this is a no-op there, but makes the intent explicit. if features.FeatureGates().Enabled(features.WatchListClient) { type featureGatesSetter interface { features.Gates Set(features.Feature, bool) error } if gates, ok := features.FeatureGates().(featureGatesSetter); ok { _ = gates.Set(features.WatchListClient, false) } } os.Exit(m.Run()) } ================================================ FILE: source/node.go ================================================ /* Copyright 2017 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 source import ( "context" "fmt" "text/template" log "github.com/sirupsen/logrus" v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" "k8s.io/client-go/kubernetes" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" ) // nodeSource is an implementation of Source for Kubernetes Node objects. // // +externaldns:source:name=node // +externaldns:source:category=Kubernetes Core // +externaldns:source:description=Creates DNS entries based on Kubernetes Node resources // +externaldns:source:resources=Node // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=false // +externaldns:source:events=true type nodeSource struct { client kubernetes.Interface annotationFilter string fqdnTemplate *template.Template combineFQDNAnnotation bool nodeInformer coreinformers.NodeInformer labelSelector labels.Selector excludeUnschedulable bool exposeInternalIPv6 bool } // NewNodeSource creates a new nodeSource with the given config. func NewNodeSource( ctx context.Context, kubeClient kubernetes.Interface, cfg *Config) (Source, error) { tmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } // Use shared informers to listen for add/update/delete of nodes. // Set resync period to 0, to prevent processing when nothing has changed informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0) nodeInformer := informerFactory.Core().V1().Nodes() // Add default resource event handler to properly initialize informer. _, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } return &nodeSource{ client: kubeClient, annotationFilter: cfg.AnnotationFilter, fqdnTemplate: tmpl, combineFQDNAnnotation: cfg.CombineFQDNAndAnnotation, nodeInformer: nodeInformer, labelSelector: cfg.LabelFilter, excludeUnschedulable: cfg.ExcludeUnschedulable, exposeInternalIPv6: cfg.ExposeInternalIPv6, }, nil } // Endpoints returns endpoint objects for each service that should be processed. func (ns *nodeSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { nodes, err := ns.nodeInformer.Lister().List(ns.labelSelector) if err != nil { return nil, err } nodes, err = annotations.Filter(nodes, ns.annotationFilter) if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, 0) // create endpoints for all nodes for _, node := range nodes { if annotations.IsControllerMismatch(node, types.Node) { continue } if node.Spec.Unschedulable && ns.excludeUnschedulable { log.Debugf("Skipping node %s because it is unschedulable", node.Name) continue } log.Debugf("creating endpoint for node %s", node.Name) // Only generate node name endpoints when there's no template or when combining var nodeEndpoints []*endpoint.Endpoint if ns.fqdnTemplate == nil || ns.combineFQDNAnnotation { nodeEndpoints, err = ns.endpointsForDNSNames(node, []string{node.Name}) if err != nil { return nil, err } } nodeEndpoints, err = fqdn.CombineWithTemplatedEndpoints( nodeEndpoints, ns.fqdnTemplate, ns.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { return ns.endpointsFromNodeTemplate(node) }, ) if err != nil { return nil, err } if len(nodeEndpoints) == 0 { log.Debugf("No endpoints could be generated from node %s", node.Name) continue } endpoint.AttachRefObject(nodeEndpoints, events.NewObjectReference(node, types.Node)) endpoints = append(endpoints, nodeEndpoints...) } return MergeEndpoints(endpoints), nil } func (ns *nodeSource) AddEventHandler(_ context.Context, handler func()) { _, _ = ns.nodeInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } // endpointsFromNodeTemplate creates endpoints using DNS names from the FQDN template. func (ns *nodeSource) endpointsFromNodeTemplate(node *v1.Node) ([]*endpoint.Endpoint, error) { names, err := fqdn.ExecTemplate(ns.fqdnTemplate, node) if err != nil { return nil, err } for _, name := range names { log.Debugf("applied template for %s, converting to %s", node.Name, name) } return ns.endpointsForDNSNames(node, names) } // endpointsForDNSNames creates endpoints for the given DNS names using the node's addresses. func (ns *nodeSource) endpointsForDNSNames(node *v1.Node, dnsNames []string) ([]*endpoint.Endpoint, error) { ttl := annotations.TTLFromAnnotations(node.Annotations, fmt.Sprintf("node/%s", node.Name)) addrs := annotations.TargetsFromTargetAnnotation(node.Annotations) if len(addrs) == 0 { var err error addrs, err = ns.nodeAddresses(node) if err != nil { return nil, fmt.Errorf("failed to get node address from %s: %w", node.Name, err) } } var endpoints []*endpoint.Endpoint for _, dns := range dnsNames { log.Debugf("adding endpoint with %d targets", len(addrs)) for _, addr := range addrs { ep := endpoint.NewEndpointWithTTL(dns, endpoint.SuitableType(addr), ttl, addr) ep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf("node/%s", node.Name)) log.Debugf("adding endpoint %s target %s", ep, addr) endpoints = append(endpoints, ep) } } return MergeEndpoints(endpoints), nil } // nodeAddress returns the node's externalIP and if that's not found, the node's internalIP // basically what k8s.io/kubernetes/pkg/util/node.GetPreferredNodeAddress does func (ns *nodeSource) nodeAddresses(node *v1.Node) ([]string, error) { addresses := map[v1.NodeAddressType][]string{ v1.NodeExternalIP: {}, v1.NodeInternalIP: {}, } var internalIpv6Addresses []string for _, addr := range node.Status.Addresses { // IPv6 InternalIP addresses have special handling. // Refer to https://github.com/kubernetes-sigs/external-dns/pull/5192 for more details. if addr.Type == v1.NodeInternalIP && endpoint.SuitableType(addr.Address) == endpoint.RecordTypeAAAA { internalIpv6Addresses = append(internalIpv6Addresses, addr.Address) } addresses[addr.Type] = append(addresses[addr.Type], addr.Address) } if len(addresses[v1.NodeExternalIP]) > 0 { if ns.exposeInternalIPv6 { return append(addresses[v1.NodeExternalIP], internalIpv6Addresses...), nil } return addresses[v1.NodeExternalIP], nil } if len(addresses[v1.NodeInternalIP]) > 0 { return addresses[v1.NodeInternalIP], nil } return nil, fmt.Errorf("could not find node address for %s", node.Name) } ================================================ FILE: source/node_fqdn_test.go ================================================ /* 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 source import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" ) func TestNodeSourceNewNodeSourceWithFqdn(t *testing.T) { for _, tt := range []struct { title string annotationFilter string fqdnTemplate string expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "complex template", expectError: false, fqdnTemplate: "{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.ext-dns.test.com", }, } { t.Run(tt.title, func(t *testing.T) { _, err := NewNodeSource( t.Context(), fake.NewClientset(), &Config{ AnnotationFilter: tt.annotationFilter, FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: false, ExcludeUnschedulable: true, ExposeInternalIPv6: true, LabelFilter: labels.Everything(), }, ) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func TestNodeSourceFqdnTemplatingExamples(t *testing.T) { for _, tt := range []struct { title string nodes []*v1.Node fqdnTemplate string expected []*endpoint.Endpoint combineFQDN bool }{ { title: "templating expansion with multiple domains", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "ip-10-1-176-5.internal", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeInternalIP, Address: "10.1.176.1"}, {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, }, }, }, }, fqdnTemplate: "{{.Name}}.domainA.com,{{.Name}}.domainB.com", expected: []*endpoint.Endpoint{ {DNSName: "ip-10-1-176-5.internal.domainA.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.176.1"}}, {DNSName: "ip-10-1-176-5.internal.domainA.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, {DNSName: "ip-10-1-176-5.internal.domainB.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.176.1"}}, {DNSName: "ip-10-1-176-5.internal.domainB.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, }, }, { title: "templating contains namespace when node namespace is not a valid variable", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "node-name", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeInternalIP, Address: "10.1.176.1"}, }, }, }, }, fqdnTemplate: "{{.Name}}.domainA.com,{{ .Name }}.{{ .Namespace }}.example.tld", expected: []*endpoint.Endpoint{ {DNSName: "node-name.domainA.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.176.1"}}, {DNSName: "node-name..example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.176.1"}}, }, }, { title: "templating with external IP and range of addresses", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "ip-10-1-176-1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, }, }, }, }, fqdnTemplate: "{{ range .Status.Addresses }}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}ip-{{ .Address | replace \".\" \"-\" }}{{ break }}{{ end }}{{ end }}.example.com", expected: []*endpoint.Endpoint{ {DNSName: "ip-243-186-136-160.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, {DNSName: "ip-243-186-136-160.example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, }, }, { title: "templating with name definition and ipv4 check", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "node-name-ip", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, }, }, }, }, fqdnTemplate: "{{ $name := .Name }}{{ range .Status.Addresses }}{{if (isIPv4 .Address)}}{{ $name }}.ipv4{{ break }}{{ end }}{{ end }}.example.com", expected: []*endpoint.Endpoint{ {DNSName: "node-name-ip.ipv4.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, {DNSName: "node-name-ip.ipv4.example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, }, }, { title: "templating with hostname annotation", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "ip-10-1-176-1", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "ip-10-1-176-1.internal.domain.com", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, }, }, }, }, fqdnTemplate: "{{.Name}}.example.com", expected: []*endpoint.Endpoint{ {DNSName: "ip-10-1-176-1.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, {DNSName: "ip-10-1-176-1.example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"fc00:f853:ccd:e793::1"}}, }, }, { title: "templating when target annotation and no external IP", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "node-name", Labels: nil, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "203.2.45.22", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, {Type: v1.NodeInternalIP, Address: "fc00:f853:ccd:e793::1"}, }, }, }, }, fqdnTemplate: "{{.Name}}.example.com", expected: []*endpoint.Endpoint{ {DNSName: "node-name.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.2.45.22"}}, }, }, { title: "templating with simple annotation expansion", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "node-name", Annotations: map[string]string{ "workload": "cluster-resources", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.{{ .Annotations.workload }}.domain.tld", expected: []*endpoint.Endpoint{ {DNSName: "node-name.cluster-resources.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, }, }, { title: "templating with complex labels expansion", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "node-name", Labels: map[string]string{ "topology.kubernetes.io/region": "eu-west-1", }, Annotations: nil, }, Spec: v1.NodeSpec{ Unschedulable: false, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.{{ index .ObjectMeta.Labels \"topology.kubernetes.io/region\" }}.domain.tld", expected: []*endpoint.Endpoint{ {DNSName: "node-name.eu-west-1.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, }, }, { title: "templating with shared all domain", nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "node-name-1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "243.186.136.160"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node-name-2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "243.186.136.178"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.domain.tld,all.example.com", expected: []*endpoint.Endpoint{ {DNSName: "all.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160", "243.186.136.178"}}, {DNSName: "node-name-1.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, {DNSName: "node-name-2.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.178"}}, }, }, { title: "templating with shared all domain and fqdn combination annotation", combineFQDN: true, nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{Name: "node-name-1"}, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "243.186.136.160"}}, }, }, { ObjectMeta: metav1.ObjectMeta{Name: "node-name-2"}, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "243.186.136.178"}}, }, }, }, fqdnTemplate: "{{ .Name }}.domain.tld,all.example.com", expected: []*endpoint.Endpoint{ {DNSName: "all.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160", "243.186.136.178"}}, {DNSName: "node-name-1.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, {DNSName: "node-name-2.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.178"}}, {DNSName: "node-name-1", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.160"}}, {DNSName: "node-name-2", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"243.186.136.178"}}, }, }, { title: "templating with kind-based FQDNs", fqdnTemplate: `{{ if eq .Kind "Pod" }}{{.Name}}.pod.tld{{ end }} {{ if eq .Kind "Node" }}{{.Name}}.{{.Status.NodeInfo.Architecture}}.node.tld{{ end }}`, expected: []*endpoint.Endpoint{ {DNSName: "node-name-1.arm64.node.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.1"}}, {DNSName: "node-name-2.x86_64.node.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.0.2"}}, }, combineFQDN: false, nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{Name: "node-name-1"}, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "10.0.0.1"}}, NodeInfo: v1.NodeSystemInfo{ Architecture: "arm64", }, }, Spec: v1.NodeSpec{}, }, { ObjectMeta: metav1.ObjectMeta{Name: "node-name-2"}, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "10.0.0.2"}}, NodeInfo: v1.NodeSystemInfo{ Architecture: "x86_64", }, }, }, }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() for _, node := range tt.nodes { _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewNodeSource( t.Context(), kubeClient, &Config{ FQDNTemplate: tt.fqdnTemplate, ExcludeUnschedulable: true, ExposeInternalIPv6: true, CombineFQDNAndAnnotation: tt.combineFQDN, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } ================================================ FILE: source/node_test.go ================================================ /* Copyright 2019 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 source import ( "fmt" "maps" "math/rand" "testing" "time" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/mock" corev1lister "k8s.io/client-go/listers/core/v1" "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/source/annotations" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" ) func TestNodeSource(t *testing.T) { t.Parallel() t.Run("NewNodeSource", testNodeSourceNewNodeSource) t.Run("Endpoints", testNodeSourceEndpoints) t.Run("EndpointsIPv6", testNodeEndpointsWithIPv6) } // testNodeSourceNewNodeSource tests that NewNodeService doesn't return an error. func testNodeSourceNewNodeSource(t *testing.T) { t.Parallel() for _, ti := range []struct { title string annotationFilter string fqdnTemplate string expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "complex template", expectError: false, fqdnTemplate: "{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.ext-dns.test.com", }, { title: "non-empty annotation filter label", expectError: false, annotationFilter: "kubernetes.io/ingress.class=nginx", }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() _, err := NewNodeSource( t.Context(), fake.NewClientset(), &Config{ AnnotationFilter: ti.annotationFilter, FQDNTemplate: ti.fqdnTemplate, LabelFilter: labels.Everything(), ExcludeUnschedulable: true, ExposeInternalIPv6: true, }, ) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } // testNodeSourceEndpoints tests that various node generate the correct endpoints. func testNodeSourceEndpoints(t *testing.T) { for _, tc := range []struct { title string annotationFilter string labelSelector string fqdnTemplate string nodeName string nodeAddresses []v1.NodeAddress labels map[string]string annotations map[string]string excludeUnschedulable bool // default to false exposeInternalIPv6 bool // default to true for this version. Change later when the next minor version is released. unschedulable bool // default to false expected []*endpoint.Endpoint expectError bool expectedLogs []string expectedAbsentLogs []string }{ { title: "node with short hostname returns one endpoint", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "node with fqdn returns one endpoint", nodeName: "node1.example.org", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "ipv6 node with fqdn returns one endpoint", nodeName: "node1.example.org", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2001:DB8::8"}}, expected: []*endpoint.Endpoint{ {RecordType: "AAAA", DNSName: "node1.example.org", Targets: endpoint.Targets{"2001:DB8::8"}}, }, }, { title: "node with fqdn template returns endpoint with expanded hostname", fqdnTemplate: "{{.Name}}.example.org", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "node with fqdn and fqdn template returns one endpoint", fqdnTemplate: "{{.Name}}.example.org", nodeName: "node1.example.org", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1.example.org.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "node with fqdn template returns two endpoints with multiple IP addresses and expanded hostname", fqdnTemplate: "{{.Name}}.example.org", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {Type: v1.NodeExternalIP, Address: "5.6.7.8"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"1.2.3.4", "5.6.7.8"}}, }, }, { title: "node with fqdn template returns two endpoints with dual-stack IP addresses and expanded hostname", fqdnTemplate: "{{.Name}}.example.org", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::8"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, {RecordType: "AAAA", DNSName: "node1.example.org", Targets: endpoint.Targets{"2001:DB8::8"}}, }, }, { title: "node with both external and internal IP returns an endpoint with external IP", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {Type: v1.NodeInternalIP, Address: "2.3.4.5"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "node with both external, internal, and IPv6 IP returns endpoints with external IPs", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, {Type: v1.NodeInternalIP, Address: "2.3.4.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::8"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, {RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::8"}}, }, }, { title: "node with only internal IP returns an endpoint with internal IP", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2.3.4.5"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"2.3.4.5"}}, }, }, { title: "node with only internal IPs returns endpoints with internal IPs", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2.3.4.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::8"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"2.3.4.5"}}, {RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::8"}}, }, }, { title: "node with only internal IPs with expose internal IP as false shouldn't return AAAA endpoints with internal IPs", nodeName: "node1", exposeInternalIPv6: false, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2.3.4.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::9"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"2.3.4.5"}}, {RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::9"}}, }, }, { title: "node with neither external nor internal IP returns no endpoints", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{}, expectError: true, }, { title: "node with target annotation", nodeName: "node1.example.org", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "203.2.45.7", }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1.example.org", Targets: endpoint.Targets{"203.2.45.7"}}, }, }, { title: "annotated node without annotation filter returns endpoint", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ "service.beta.kubernetes.io/external-traffic": "OnlyLocal", }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "annotated node with matching annotation filter returns endpoint", annotationFilter: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ "service.beta.kubernetes.io/external-traffic": "OnlyLocal", }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "annotated node with non-matching annotation filter returns nothing", annotationFilter: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ "service.beta.kubernetes.io/external-traffic": "SomethingElse", }, expected: []*endpoint.Endpoint{}, }, { title: "labeled node with matching label selector returns endpoint", labelSelector: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, labels: map[string]string{ "service.beta.kubernetes.io/external-traffic": "OnlyLocal", }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "labeled node with non-matching label selector returns nothing", labelSelector: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, labels: map[string]string{ "service.beta.kubernetes.io/external-traffic": "SomethingElse", }, expected: []*endpoint.Endpoint{}, }, { title: "our controller type is dns-controller", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "different controller types are ignored", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ annotations.ControllerKey: "not-dns-controller", }, expected: []*endpoint.Endpoint{}, }, { title: "ttl not annotated should have RecordTTL.IsConfigured set to false", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(0)}, }, }, { title: "ttl annotated but invalid should have RecordTTL.IsConfigured set to false", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ annotations.TtlKey: "foo", }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(0)}, }, }, { title: "ttl annotated and is valid should set Record.TTL", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ annotations.TtlKey: "10", }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(10)}, }, }, { title: "unschedulable node return nothing with excludeUnschedulable=true", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, unschedulable: true, excludeUnschedulable: true, expected: []*endpoint.Endpoint{}, expectedLogs: []string{ "Skipping node node1 because it is unschedulable", }, }, { title: "unschedulable node returns node with excludeUnschedulable=false", nodeName: "node1", nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, unschedulable: true, excludeUnschedulable: false, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, }, expectedAbsentLogs: []string{ "Skipping node node1 because it is unschedulable", }, }, { title: "provider-specific annotation is not supported and is ignored", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}}, annotations: map[string]string{ annotations.AWSPrefix + "weight": "10", }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, } { t.Run(tc.title, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) labelSelector := labels.Everything() if tc.labelSelector != "" { var err error labelSelector, err = labels.Parse(tc.labelSelector) require.NoError(t, err) } // Create a Kubernetes testing client kubeClient := fake.NewClientset() node := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: tc.nodeName, Labels: tc.labels, Annotations: tc.annotations, }, Spec: v1.NodeSpec{ Unschedulable: tc.unschedulable, }, Status: v1.NodeStatus{ Addresses: tc.nodeAddresses, }, } _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}) require.NoError(t, err) // Create our object under test and get the endpoints. client, err := NewNodeSource( t.Context(), kubeClient, &Config{ AnnotationFilter: tc.annotationFilter, FQDNTemplate: tc.fqdnTemplate, LabelFilter: labelSelector, ExposeInternalIPv6: tc.exposeInternalIPv6, ExcludeUnschedulable: tc.excludeUnschedulable, }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) for _, entry := range tc.expectedLogs { logtest.TestHelperLogContains(entry, hook, t) } for _, entry := range tc.expectedAbsentLogs { logtest.TestHelperLogNotContains(entry, hook, t) } }) } } func testNodeEndpointsWithIPv6(t *testing.T) { for _, tc := range []struct { title string annotationFilter string labelSelector string fqdnTemplate string nodeName string nodeAddresses []v1.NodeAddress labels map[string]string annotations map[string]string excludeUnschedulable bool // defaults to false exposeInternalIPv6 bool // default to true for this version. Change later when the next minor version is released. unschedulable bool // default to false expected []*endpoint.Endpoint expectError bool }{ { title: "node with only internal IPs should return internal IPvs irrespective of exposeInternalIPv6", nodeName: "node1", exposeInternalIPv6: false, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeInternalIP, Address: "2.3.4.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::9"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"2.3.4.5"}}, {RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::9"}}, }, }, { title: "node with both external, internal, and IPv6 IP returns endpoints with external IPs", nodeName: "node1", exposeInternalIPv6: false, nodeAddresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: "1.2.3.4"}, { Type: v1.NodeExternalIP, Address: "2001:DB8::8", }, {Type: v1.NodeInternalIP, Address: "2001:DB8::9"}}, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.4"}}, {RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::8"}}, }, }, { title: "node with both external and internal IPs should return internal IPv6 if exposeInternalIPv6 is true", nodeName: "node1", exposeInternalIPv6: true, nodeAddresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "1.2.3.5"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::9"}, }, expected: []*endpoint.Endpoint{ {RecordType: "A", DNSName: "node1", Targets: endpoint.Targets{"1.2.3.5"}}, {RecordType: "AAAA", DNSName: "node1", Targets: endpoint.Targets{"2001:DB8::9"}}, }, }, } { labelSelector := labels.Everything() if tc.labelSelector != "" { var err error labelSelector, err = labels.Parse(tc.labelSelector) require.NoError(t, err) } // Create a Kubernetes testing client kubeClient := fake.NewClientset() node := &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: tc.nodeName, Labels: tc.labels, Annotations: tc.annotations, }, Spec: v1.NodeSpec{ Unschedulable: tc.unschedulable, }, Status: v1.NodeStatus{ Addresses: tc.nodeAddresses, }, } _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}) require.NoError(t, err) // Create our object under test and get the endpoints. client, err := NewNodeSource( t.Context(), kubeClient, &Config{ AnnotationFilter: tc.annotationFilter, FQDNTemplate: tc.fqdnTemplate, LabelFilter: labelSelector, ExposeInternalIPv6: tc.exposeInternalIPv6, ExcludeUnschedulable: tc.excludeUnschedulable, }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) // TODO; when all resources have the resource label, we could add this check to the validateEndpoints function. for _, ep := range endpoints { require.Contains(t, ep.Labels, endpoint.ResourceLabelKey) } } } func TestResourceLabelIsSetForEachNodeEndpoint(t *testing.T) { kubeClient := fake.NewClientset() nodes := helperNodeBuilder(). withNode(map[string]string{"tenant": "1"}). withNode(map[string]string{"tenant": "2"}). withNode(map[string]string{"tenant": "3"}). withNode(map[string]string{"tenant": "4"}). build() for _, node := range nodes.Items { _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), &node, metav1.CreateOptions{}) require.NoError(t, err, "Failed to create node %s", node.Name) } client, err := NewNodeSource( t.Context(), kubeClient, &Config{ LabelFilter: labels.Everything(), }, ) require.NoError(t, err) got, err := client.Endpoints(t.Context()) require.NoError(t, err) for _, ep := range got { assert.NotEmpty(t, ep.Labels, "Labels should not be empty for endpoint %s", ep.DNSName) assert.Contains(t, ep.Labels, endpoint.ResourceLabelKey) } } func TestProcessEndpoint_Node_RefObjectExist(t *testing.T) { elements := []runtime.Object{ &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: "foo", Annotations: map[string]string{ annotations.HostnameKey: "foo.example.com", annotations.TargetKey: "1.2.3", }, UID: "uid-1", }, }, &v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: "bar", Annotations: map[string]string{ annotations.HostnameKey: "bar.example.com", annotations.TargetKey: "3.4.5", }, UID: "uid-2", }, }, } fakeClient := fake.NewClientset(elements...) client, err := NewNodeSource( t.Context(), fakeClient, &Config{ LabelFilter: labels.Everything(), }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) require.NoError(t, err) testutils.AssertEndpointsHaveRefObject(t, endpoints, types.Node, len(elements)) } func TestNodeSource_AddEventHandler(t *testing.T) { fakeInformer := new(fakeNodeInformer) inf := testInformer{} fakeInformer.On("Informer").Return(&inf) nSource := &nodeSource{ nodeInformer: fakeInformer, } handlerCalled := false handler := func() { handlerCalled = true } nSource.AddEventHandler(t.Context(), handler) fakeInformer.AssertNumberOfCalls(t, "Informer", 1) assert.False(t, handlerCalled) assert.Equal(t, 1, inf.times) } type fakeNodeInformer struct { mock.Mock } func (f *fakeNodeInformer) Informer() cache.SharedIndexInformer { args := f.Called() return args.Get(0).(cache.SharedIndexInformer) } func (f *fakeNodeInformer) Lister() corev1lister.NodeLister { return corev1lister.NewNodeLister(f.Informer().GetIndexer()) } type nodeListBuilder struct { nodes []v1.Node } func helperNodeBuilder() *nodeListBuilder { return &nodeListBuilder{nodes: []v1.Node{}} } func (b *nodeListBuilder) withNode(labels map[string]string) *nodeListBuilder { idx := len(b.nodes) + 1 nodeName := fmt.Sprintf("ip-10-1-176-%d.internal", idx) b.nodes = append(b.nodes, v1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: nodeName, Labels: func() map[string]string { base := map[string]string{ "test-label": "test-value", "name": nodeName, "topology.kubernetes.io/region": "eu-west-1", "node.kubernetes.io/lifecycle": "spot", } maps.Copy(base, labels) return base }(), Annotations: map[string]string{ "volumes.kubernetes.io/controller-managed-attach-detach": "true", "alpha.kubernetes.io/provided-node-ip": fmt.Sprintf("10.1.176.%d", idx), "external-dns.alpha.kubernetes.io/hostname": fmt.Sprintf("node-%d.example.com", idx), }, }, Spec: v1.NodeSpec{ Unschedulable: false, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeInternalIP, Address: fmt.Sprintf("10.1.176.%d", idx)}, {Type: v1.NodeInternalIP, Address: fmt.Sprintf("fc00:f853:ccd:e793::%d", idx)}, }, }, }) return b } func (b *nodeListBuilder) build() v1.NodeList { if len(b.nodes) > 1 { // Shuffle the result to ensure randomness in the order. rand.New(rand.NewSource(time.Now().UnixNano())) rand.Shuffle(len(b.nodes), func(i, j int) { b.nodes[i], b.nodes[j] = b.nodes[j], b.nodes[i] }) } return v1.NodeList{Items: b.nodes} } ================================================ FILE: source/openshift_route.go ================================================ /* Copyright 2017 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 source import ( "context" "fmt" "text/template" "time" routev1 "github.com/openshift/api/route/v1" "github.com/openshift/client-go/route/clientset/versioned" extInformers "github.com/openshift/client-go/route/informers/externalversions" routeInformer "github.com/openshift/client-go/route/informers/externalversions/route/v1" log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" ) // ocpRouteSource is an implementation of Source for OpenShift Route objects. // The Route implementation will use the Route spec.host field for the hostname, // and the Route status' canonicalHostname field as the target. // The annotations.TargetKey can be used to explicitly set an alternative // endpoint, if desired. // // +externaldns:source:name=openshift-route // +externaldns:source:category=OpenShift // +externaldns:source:description=Creates DNS entries from OpenShift Route resources // +externaldns:source:resources=Route.route.openshift.io // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true type ocpRouteSource struct { client versioned.Interface namespace string annotationFilter string fqdnTemplate *template.Template combineFQDNAnnotation bool ignoreHostnameAnnotation bool routeInformer routeInformer.RouteInformer labelSelector labels.Selector ocpRouterName string } // NewOcpRouteSource creates a new ocpRouteSource with the given config. func NewOcpRouteSource( ctx context.Context, ocpClient versioned.Interface, cfg *Config, ) (Source, error) { tmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } // Use a shared informer to listen for add/update/delete of Routes in the specified namespace. // Set resync period to 0, to prevent processing when nothing has changed. informerFactory := extInformers.NewSharedInformerFactoryWithOptions(ocpClient, 0*time.Second, extInformers.WithNamespace(cfg.Namespace)) informer := informerFactory.Route().V1().Routes() // Add default resource event handlers to properly initialize informer. _, _ = informer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } return &ocpRouteSource{ client: ocpClient, namespace: cfg.Namespace, annotationFilter: cfg.AnnotationFilter, fqdnTemplate: tmpl, combineFQDNAnnotation: cfg.CombineFQDNAndAnnotation, ignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, routeInformer: informer, labelSelector: cfg.LabelFilter, ocpRouterName: cfg.OCPRouterName, }, nil } func (ors *ocpRouteSource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for openshift route") // Right now there is no way to remove event handler from informer, see: // https://github.com/kubernetes/kubernetes/issues/79610 _, _ = ors.routeInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all OpenShift Route resources on all namespaces, unless an explicit namespace // is specified in ocpRouteSource. func (ors *ocpRouteSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { ocpRoutes, err := ors.routeInformer.Lister().Routes(ors.namespace).List(ors.labelSelector) if err != nil { return nil, err } ocpRoutes, err = annotations.Filter(ocpRoutes, ors.annotationFilter) if err != nil { return nil, err } endpoints := []*endpoint.Endpoint{} for _, ocpRoute := range ocpRoutes { if annotations.IsControllerMismatch(ocpRoute, types.OpenShiftRoute) { continue } orEndpoints := ors.endpointsFromOcpRoute(ocpRoute, ors.ignoreHostnameAnnotation) // apply template if host is missing on OpenShift Route orEndpoints, err = fqdn.CombineWithTemplatedEndpoints( orEndpoints, ors.fqdnTemplate, ors.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { return ors.endpointsFromTemplate(ocpRoute) }, ) if err != nil { return nil, err } if endpoint.HasNoEmptyEndpoints(orEndpoints, types.OpenShiftRoute, ocpRoute) { continue } log.Debugf("Endpoints generated from OpenShift Route: %s/%s: %v", ocpRoute.Namespace, ocpRoute.Name, orEndpoints) endpoints = append(endpoints, orEndpoints...) } return MergeEndpoints(endpoints), nil } func (ors *ocpRouteSource) endpointsFromTemplate(ocpRoute *routev1.Route) ([]*endpoint.Endpoint, error) { hostnames, err := fqdn.ExecTemplate(ors.fqdnTemplate, ocpRoute) if err != nil { return nil, err } resource := fmt.Sprintf("route/%s/%s", ocpRoute.Namespace, ocpRoute.Name) ttl := annotations.TTLFromAnnotations(ocpRoute.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(ocpRoute.Annotations) if len(targets) == 0 { targetsFromRoute, _ := ors.getTargetsFromRouteStatus(ocpRoute.Status) targets = targetsFromRoute } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ocpRoute.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } // endpointsFromOcpRoute extracts the endpoints from a OpenShift Route object func (ors *ocpRouteSource) endpointsFromOcpRoute(ocpRoute *routev1.Route, ignoreHostnameAnnotation bool) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint resource := fmt.Sprintf("route/%s/%s", ocpRoute.Namespace, ocpRoute.Name) ttl := annotations.TTLFromAnnotations(ocpRoute.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(ocpRoute.Annotations) targetsFromRoute, host := ors.getTargetsFromRouteStatus(ocpRoute.Status) if len(targets) == 0 { targets = targetsFromRoute } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ocpRoute.Annotations) if host != "" { endpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } // Skip endpoints if we do not want entries from annotations if !ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(ocpRoute.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } return endpoints } // getTargetsFromRouteStatus returns the router's canonical hostname and host // either for the given router if it admitted the route // or for the first (in the status list) router that admitted the route. func (ors *ocpRouteSource) getTargetsFromRouteStatus(status routev1.RouteStatus) (endpoint.Targets, string) { for _, ing := range status.Ingress { // if this Ingress didn't admit the route or it doesn't have the canonical hostname, then ignore it if ingressConditionStatus(&ing, routev1.RouteAdmitted) != corev1.ConditionTrue || ing.RouterCanonicalHostname == "" { continue } // if the router name is specified for the Route source and it matches the route's ingress name, then return it if ors.ocpRouterName != "" && ors.ocpRouterName == ing.RouterName { return endpoint.Targets{ing.RouterCanonicalHostname}, ing.Host } // if the router name is not specified in the Route source then return the first ingress if ors.ocpRouterName == "" { return endpoint.Targets{ing.RouterCanonicalHostname}, ing.Host } } return endpoint.Targets{}, "" } func ingressConditionStatus(ingress *routev1.RouteIngress, t routev1.RouteIngressConditionType) corev1.ConditionStatus { for _, condition := range ingress.Conditions { if t != condition.Type { continue } return condition.Status } return corev1.ConditionUnknown } ================================================ FILE: source/openshift_route_fqdn_test.go ================================================ /* Copyright 2026 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 source import ( "testing" routev1 "github.com/openshift/api/route/v1" "github.com/openshift/client-go/route/clientset/versioned/fake" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) func TestOpenShiftFqdnTemplatingExamples(t *testing.T) { for _, tt := range []struct { title string ocpRoute []*routev1.Route fqdnTemplate string combineFqdn bool expected []*endpoint.Endpoint }{ { title: "simple templating", fqdnTemplate: "{{.Name}}.tld.com", expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-default.example.com"}}, {DNSName: "my-gateway.tld.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-default.example.com"}}, }, ocpRoute: []*routev1.Route{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: routev1.RouteSpec{ Host: "example.org", To: routev1.RouteTargetReference{ Kind: "Service", Name: "my-service", }, TLS: &routev1.TLSConfig{}, }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "example.org", RouterCanonicalHostname: "router-default.example.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, }, }, { title: "templating with fqdn combine disabled", fqdnTemplate: "{{.Name}}.tld.com", expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-default.example.com"}}, }, combineFqdn: true, ocpRoute: []*routev1.Route{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "default", }, Spec: routev1.RouteSpec{}, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "example.org", RouterCanonicalHostname: "router-default.example.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, }, }, { title: "templating with namespace", fqdnTemplate: "{{.Name}}.{{.Namespace}}.tld.com", expected: []*endpoint.Endpoint{ {DNSName: "my-gateway.kube-system.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.0"}}, }, combineFqdn: true, ocpRoute: []*routev1.Route{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-gateway", Namespace: "kube-system", Annotations: map[string]string{ annotations.TargetKey: "10.1.1.0", }, }, }, }, }, { title: "templating with complex fqdn template", fqdnTemplate: "{{ .Name }}.{{ .Namespace }}.tld.com,{{ if .Labels.env }}{{ .Labels.env }}.private{{ end }}", expected: []*endpoint.Endpoint{ {DNSName: "no-labels-route-3.default.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.3"}}, {DNSName: "route-2.default.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.3"}}, {DNSName: "dev.private", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.3"}}, {DNSName: "route-1.kube-system.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.0"}}, {DNSName: "prod.private", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.0"}}, }, combineFqdn: true, ocpRoute: []*routev1.Route{ { ObjectMeta: metav1.ObjectMeta{ Name: "route-1", Namespace: "kube-system", Labels: map[string]string{ "env": "prod", }, Annotations: map[string]string{ "env": "prod", annotations.TargetKey: "10.1.1.0", }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "route-2", Namespace: "default", Labels: map[string]string{ "env": "dev", }, Annotations: map[string]string{ annotations.TargetKey: "10.1.1.3", }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "no-labels-route-3", Namespace: "default", Annotations: map[string]string{ annotations.TargetKey: "10.1.1.3", }, }, }, }, }, { title: "template that skips when field is missing", fqdnTemplate: "{{ if and .Spec.Port .Spec.Port.TargetPort }}{{ .Name }}.{{ .Spec.Port.TargetPort }}.tld.com{{ end }}", expected: []*endpoint.Endpoint{ {DNSName: "route-1.80.tld.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.0"}}, }, ocpRoute: []*routev1.Route{ { ObjectMeta: metav1.ObjectMeta{ Name: "route-1", Namespace: "kube-system", Annotations: map[string]string{ annotations.TargetKey: "10.1.1.0", }, }, Spec: routev1.RouteSpec{ Port: &routev1.RoutePort{ TargetPort: intstr.FromString("80"), }, }, Status: routev1.RouteStatus{}, }, { ObjectMeta: metav1.ObjectMeta{ Name: "route-2", Namespace: "default", Annotations: map[string]string{ annotations.TargetKey: "10.1.1.3", }, }, }, }, }, { title: "get canonical hostnames for admitted routes", fqdnTemplate: "{{ $name := .Name }}{{ range $ingress := .Status.Ingress }}{{ range $ingress.Conditions }}{{ if and (eq .Type \"Admitted\") (eq .Status \"True\") }}{{ $ingress.Host }},{{ end }}{{ end }}{{ end }}", expected: []*endpoint.Endpoint{ {DNSName: "cluster.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, {DNSName: "apps.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, }, ocpRoute: []*routev1.Route{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-route", Namespace: "kube-system", Annotations: map[string]string{}, }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "cluster.example.org", RouterCanonicalHostname: "router-dmz.apps.dmz.example.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, { Host: "apps.example.org", RouterCanonicalHostname: "router-internal.apps.internal.example.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, { Host: "wrong.example.org", RouterCanonicalHostname: "router-default.apps.cluster.example.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionFalse, }, }, }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "route-2", Namespace: "default", Annotations: map[string]string{ annotations.TargetKey: "10.1.1.3", }, }, }, }, }, { title: "get canonical hostnames for admitted routes without prefix", fqdnTemplate: "{{ $name := .Name }}{{ range $ingress := .Status.Ingress }}{{ range $ingress.Conditions }}{{ if and (eq .Type \"Admitted\") (eq .Status \"True\") }}{{ with $ingress.RouterCanonicalHostname }}{{ $name }}.{{ trimPrefix . \"router-\" }},{{ end }}{{ end }}{{ end }}{{ end }}", expected: []*endpoint.Endpoint{ {DNSName: "cluster.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, {DNSName: "my-route.dmz.apps.dmz.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, {DNSName: "my-route.internal.apps.internal.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"router-dmz.apps.dmz.example.com"}}, }, ocpRoute: []*routev1.Route{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-route", Namespace: "kube-system", Annotations: map[string]string{}, }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "cluster.example.org", RouterCanonicalHostname: "router-dmz.apps.dmz.example.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, { Host: "apps.example.org", RouterCanonicalHostname: "router-internal.apps.internal.example.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, { Host: "wrong.example.org", RouterCanonicalHostname: "router-default.apps.cluster.example.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionFalse, }, }, }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "route-2", Namespace: "default", Annotations: map[string]string{ annotations.TargetKey: "10.1.1.3", }, }, }, }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() for _, ocp := range tt.ocpRoute { _, err := kubeClient.RouteV1().Routes(ocp.Namespace).Create(t.Context(), ocp, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewOcpRouteSource( t.Context(), kubeClient, &Config{ Namespace: "", AnnotationFilter: "", FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: !tt.combineFqdn, LabelFilter: labels.Everything(), OCPRouterName: "", }, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } ================================================ FILE: source/openshift_route_test.go ================================================ /* Copyright 2017 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 source import ( "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "k8s.io/apimachinery/pkg/labels" routev1 "github.com/openshift/api/route/v1" "github.com/openshift/client-go/route/clientset/versioned/fake" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) type OCPRouteSuite struct { suite.Suite sc Source routeWithTargets *routev1.Route } func (suite *OCPRouteSuite) SetupTest() { fakeClient := fake.NewClientset() var err error suite.sc, err = NewOcpRouteSource( context.TODO(), fakeClient, &Config{ FQDNTemplate: "{{.Name}}", LabelFilter: labels.Everything(), }, ) suite.routeWithTargets = &routev1.Route{ Spec: routev1.RouteSpec{ Host: "my-domain.com", }, ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-targets", Annotations: map[string]string{}, }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { RouterCanonicalHostname: "apps.my-domain.com", }, }, }, } suite.NoError(err, "should initialize route source") _, err = fakeClient.RouteV1().Routes(suite.routeWithTargets.Namespace).Create(context.Background(), suite.routeWithTargets, metav1.CreateOptions{}) suite.NoError(err, "should successfully create route") } func (suite *OCPRouteSuite) TestResourceLabelIsSet() { endpoints, _ := suite.sc.Endpoints(context.Background()) for _, ep := range endpoints { suite.Equal("route/default/route-with-targets", ep.Labels[endpoint.ResourceLabelKey], "should set correct resource label") } } func TestOcpRouteSource(t *testing.T) { t.Parallel() suite.Run(t, new(OCPRouteSuite)) t.Run("Interface", testOcpRouteSourceImplementsSource) t.Run("NewOcpRouteSource", testOcpRouteSourceNewOcpRouteSource) t.Run("Endpoints", testOcpRouteSourceEndpoints) } // testOcpRouteSourceImplementsSource tests that ocpRouteSource is a valid Source. func testOcpRouteSourceImplementsSource(t *testing.T) { assert.Implements(t, (*Source)(nil), new(ocpRouteSource)) } // testOcpRouteSourceNewOcpRouteSource tests that NewOcpRouteSource doesn't return an error. func testOcpRouteSourceNewOcpRouteSource(t *testing.T) { t.Parallel() for _, ti := range []struct { title string annotationFilter string fqdnTemplate string expectError bool labelFilter string }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "non-empty annotation filter label", expectError: false, annotationFilter: "kubernetes.io/ingress.class=nginx", }, { title: "valid label selector", expectError: false, labelFilter: "app=web-external", }, } { labelSelector, err := labels.Parse(ti.labelFilter) require.NoError(t, err) t.Run(ti.title, func(t *testing.T) { t.Parallel() _, err := NewOcpRouteSource( t.Context(), fake.NewClientset(), &Config{ AnnotationFilter: ti.annotationFilter, FQDNTemplate: ti.fqdnTemplate, LabelFilter: labelSelector, }, ) if ti.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } // testOcpRouteSourceEndpoints tests that various OCP routes generate the correct endpoints. func testOcpRouteSourceEndpoints(t *testing.T) { for _, tc := range []struct { title string ocpRoute *routev1.Route expected []*endpoint.Endpoint expectError bool labelFilter string ocpRouterName string }{ { title: "route with basic hostname and route status target", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-target", }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-domain.com", RouterCanonicalHostname: "apps.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "my-domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{ "apps.my-domain.com", }, }, }, }, { title: "route with basic hostname, route status target and ocpRouterName defined", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-target", }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-domain.com", RouterName: "default", RouterCanonicalHostname: "router-default.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, ocpRouterName: "default", expected: []*endpoint.Endpoint{ { DNSName: "my-domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{ "router-default.my-domain.com", }, }, }, }, { title: "route with basic hostname, route status target, one ocpRouterName and two router canonical names", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-target", }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-domain.com", RouterName: "default", RouterCanonicalHostname: "router-default.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, { Host: "my-domain.com", RouterName: "test", RouterCanonicalHostname: "router-test.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, ocpRouterName: "default", expected: []*endpoint.Endpoint{ { DNSName: "my-domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{ "router-default.my-domain.com", }, }, }, }, { title: "route not admitted by the given router", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-target", }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-domain.com", RouterName: "default", RouterCanonicalHostname: "router-default.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, { Host: "my-domain.com", RouterName: "test", RouterCanonicalHostname: "router-test.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionFalse, }, }, }, }, }, }, ocpRouterName: "test", expected: []*endpoint.Endpoint{}, }, { title: "route not admitted by any router", ocpRoute: &routev1.Route{ Spec: routev1.RouteSpec{ Host: "my-domain.com", }, ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-target", }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-domain.com", RouterName: "default", RouterCanonicalHostname: "router-default.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionFalse, }, }, }, { Host: "my-domain.com", RouterName: "test", RouterCanonicalHostname: "router-test.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionFalse, }, }, }, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "route admitted by first appropriate router", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-target", }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-domain.com", RouterName: "default", RouterCanonicalHostname: "router-default.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionFalse, }, }, }, { Host: "my-domain.com", RouterName: "test", RouterCanonicalHostname: "router-test.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "my-domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{ "router-test.my-domain.com", }, }, }, }, { title: "route with incorrect externalDNS controller annotation", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-ignore-annotation", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/controller": "foo", }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "route with basic hostname and annotation target", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-annotation-target", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "my.site.foo.com", }, }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-annotation-domain.com", RouterName: "default", RouterCanonicalHostname: "router-default.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "my-annotation-domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{ "my.site.foo.com", }, }, }, }, { title: "route with matching labels", labelFilter: "app=web-external", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-matching-labels", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "my.site.foo.com", }, Labels: map[string]string{ "app": "web-external", "name": "service-frontend", }, }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-annotation-domain.com", RouterName: "default", RouterCanonicalHostname: "router-default.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "my-annotation-domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{ "my.site.foo.com", }, }, }, }, { title: "route without matching labels", labelFilter: "app=web-external", ocpRoute: &routev1.Route{ Spec: routev1.RouteSpec{ Host: "my-annotation-domain.com", }, ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-without-matching-labels", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "my.site.foo.com", }, Labels: map[string]string{ "app": "web-internal", "name": "service-frontend", }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "route with provider-specific annotation", ocpRoute: &routev1.Route{ ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "route-with-provider-specific", Annotations: map[string]string{ annotations.AWSPrefix + "weight": "10", }, }, Status: routev1.RouteStatus{ Ingress: []routev1.RouteIngress{ { Host: "my-domain.com", RouterCanonicalHostname: "apps.my-domain.com", Conditions: []routev1.RouteIngressCondition{ { Type: routev1.RouteAdmitted, Status: corev1.ConditionTrue, }, }, }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "my-domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{"apps.my-domain.com"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() // Create a Kubernetes testing client fakeClient := fake.NewClientset() _, err := fakeClient.RouteV1().Routes(tc.ocpRoute.Namespace).Create(t.Context(), tc.ocpRoute, metav1.CreateOptions{}) require.NoError(t, err) labelSelector, err := labels.Parse(tc.labelFilter) require.NoError(t, err) source, err := NewOcpRouteSource( t.Context(), fakeClient, &Config{ FQDNTemplate: "{{.Name}}", LabelFilter: labelSelector, OCPRouterName: tc.ocpRouterName, }, ) require.NoError(t, err) res, err := source.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, res, tc.expected) }) } } ================================================ FILE: source/pod.go ================================================ /* Copyright 2021 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. */ // TODO: // support // - set-identifier for endpoints created // - set resource aka fmt.Sprintf("pod/%s/%s", pod.Namespace, pod.Name) package source import ( "context" "fmt" "text/template" log "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" "k8s.io/client-go/kubernetes" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" "sigs.k8s.io/external-dns/source/types" ) // podSource is an implementation of Source for Kubernetes Pod objects. // // +externaldns:source:name=pod // +externaldns:source:category=Kubernetes Core // +externaldns:source:description=Creates DNS entries based on Kubernetes Pod resources // +externaldns:source:resources=Pod // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=false // +externaldns:source:events=true type podSource struct { client kubernetes.Interface namespace string fqdnTemplate *template.Template combineFQDNAnnotation bool podInformer coreinformers.PodInformer nodeInformer coreinformers.NodeInformer compatibility string ignoreNonHostNetworkPods bool podSourceDomain string } // NewPodSource creates a new podSource with the given config. func NewPodSource( ctx context.Context, kubeClient kubernetes.Interface, cfg *Config, ) (Source, error) { namespace := cfg.Namespace annotationFilter := cfg.AnnotationFilter labelSelector := cfg.LabelFilter informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace)) podInformer := informerFactory.Core().V1().Pods() nodeInformer := informerFactory.Core().V1().Nodes() err := podInformer.Informer().AddIndexers(informers.IndexerWithOptions[*corev1.Pod]( informers.IndexSelectorWithAnnotationFilter(annotationFilter), informers.IndexSelectorWithLabelSelector(labelSelector), )) if err != nil { return nil, fmt.Errorf("failed to add indexers to pod informer: %w", err) } _, _ = podInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) if cfg.FQDNTemplate == "" { // Transformer is used to reduce the memory usage of the informer. // The pod informer will otherwise store a full in-memory, go-typed copy of all pod schemas in the cluster. // If watchList is not used it will not prevent memory bursts on the initial informer sync. // When fqdnTemplate is used the entire pod needs to be provided to the rendering call, but the informer itself becomes unneeded. _ = podInformer.Informer().SetTransform(func(i any) (any, error) { pod, ok := i.(*corev1.Pod) if !ok { return nil, fmt.Errorf("object is not a pod") } // UID is retained so that event correlation works; the transform // is idempotent by construction. return &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ // Name/namespace must always be kept for the informer to work. Name: pod.Name, Namespace: pod.Namespace, // Used by the controller. This includes non-external-dns prefixed annotations. Annotations: pod.Annotations, UID: pod.UID, }, Spec: corev1.PodSpec{ HostNetwork: pod.Spec.HostNetwork, NodeName: pod.Spec.NodeName, }, Status: corev1.PodStatus{ PodIP: pod.Status.PodIP, }, }, nil }) } _, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } tmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } return &podSource{ client: kubeClient, podInformer: podInformer, nodeInformer: nodeInformer, namespace: namespace, compatibility: cfg.Compatibility, ignoreNonHostNetworkPods: cfg.IgnoreNonHostNetworkPods, podSourceDomain: cfg.PodSourceDomain, fqdnTemplate: tmpl, combineFQDNAnnotation: cfg.CombineFQDNAndAnnotation, }, nil } func (ps *podSource) AddEventHandler(_ context.Context, handler func()) { _, _ = ps.podInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } func (ps *podSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { indexKeys := ps.podInformer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors) endpoints := make([]*endpoint.Endpoint, 0) for _, key := range indexKeys { pod, err := informers.GetByKey[*corev1.Pod](ps.podInformer.Informer().GetIndexer(), key) if err != nil { continue } podEndpoints := ps.endpointsFromPodAnnotations(pod) podEndpoints, err = fqdn.CombineWithTemplatedEndpoints( podEndpoints, ps.fqdnTemplate, ps.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { return ps.endpointsFromPodTemplate(pod) }, ) if err != nil { return nil, err } endpoint.AttachRefObject(podEndpoints, events.NewObjectReference(pod, types.Pod)) endpoints = append(endpoints, podEndpoints...) } return MergeEndpoints(endpoints), nil } func (ps *podSource) endpointsFromPodAnnotations(pod *corev1.Pod) []*endpoint.Endpoint { endpointMap := make(map[endpoint.EndpointKey][]string) ps.addPodEndpointsToEndpointMap(endpointMap, pod) var endpoints []*endpoint.Endpoint for key, targets := range endpointMap { endpoints = append(endpoints, endpoint.NewEndpointWithTTL(key.DNSName, key.RecordType, key.RecordTTL, targets...)) } return endpoints } func (ps *podSource) endpointsFromPodTemplate(pod *corev1.Pod) ([]*endpoint.Endpoint, error) { hostsMap, err := ps.hostsFromTemplate(pod) if err != nil { return nil, err } var endpoints []*endpoint.Endpoint for key, targets := range hostsMap { endpoints = append(endpoints, endpoint.NewEndpointWithTTL(key.DNSName, key.RecordType, key.RecordTTL, targets...)) } return endpoints, nil } func (ps *podSource) addPodEndpointsToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod) { if ps.ignoreNonHostNetworkPods && !pod.Spec.HostNetwork { log.Debugf("skipping pod %s. hostNetwork=false", pod.Name) return } targets := annotations.TargetsFromTargetAnnotation(pod.Annotations) ps.addInternalHostnameAnnotationEndpoints(endpointMap, pod, targets) ps.addHostnameAnnotationEndpoints(endpointMap, pod, targets) ps.addKopsDNSControllerEndpoints(endpointMap, pod) ps.addPodSourceDomainEndpoints(endpointMap, pod, targets) } func (ps *podSource) addInternalHostnameAnnotationEndpoints(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string) { if domainAnnotation, ok := pod.Annotations[annotations.InternalHostnameKey]; ok { domainList := annotations.SplitHostnameAnnotation(domainAnnotation) for _, domain := range domainList { if len(targets) == 0 { addToEndpointMap(endpointMap, pod, domain, endpoint.SuitableType(pod.Status.PodIP), pod.Status.PodIP) } else { addTargetsToEndpointMap(endpointMap, pod, targets, domain) } } } } func (ps *podSource) addHostnameAnnotationEndpoints(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string) { if domainAnnotation, ok := pod.Annotations[annotations.HostnameKey]; ok { domainList := annotations.SplitHostnameAnnotation(domainAnnotation) if len(targets) == 0 { ps.addPodNodeEndpointsToEndpointMap(endpointMap, pod, domainList) } else { addTargetsToEndpointMap(endpointMap, pod, targets, domainList...) } } } func (ps *podSource) addKopsDNSControllerEndpoints(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod) { if ps.compatibility == "kops-dns-controller" { if domainAnnotation, ok := pod.Annotations[kopsDNSControllerInternalHostnameAnnotationKey]; ok { domainList := annotations.SplitHostnameAnnotation(domainAnnotation) for _, domain := range domainList { addToEndpointMap(endpointMap, pod, domain, endpoint.SuitableType(pod.Status.PodIP), pod.Status.PodIP) } } if domainAnnotation, ok := pod.Annotations[kopsDNSControllerHostnameAnnotationKey]; ok { domainList := annotations.SplitHostnameAnnotation(domainAnnotation) ps.addPodNodeEndpointsToEndpointMap(endpointMap, pod, domainList) } } } func (ps *podSource) addPodSourceDomainEndpoints(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string) { if ps.podSourceDomain != "" { domain := pod.Name + "." + ps.podSourceDomain if len(targets) == 0 { addToEndpointMap(endpointMap, pod, domain, endpoint.SuitableType(pod.Status.PodIP), pod.Status.PodIP) } addTargetsToEndpointMap(endpointMap, pod, targets, domain) } } func (ps *podSource) addPodNodeEndpointsToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, domainList []string) { node, err := ps.nodeInformer.Lister().Get(pod.Spec.NodeName) if err != nil { log.Debugf("Get node[%s] of pod[%s] error: %v; ignoring", pod.Spec.NodeName, pod.GetName(), err) return } for _, domain := range domainList { for _, address := range node.Status.Addresses { recordType := endpoint.SuitableType(address.Address) // IPv6 addresses are labeled as NodeInternalIP despite being usable externally as well. if address.Type == corev1.NodeExternalIP || (address.Type == corev1.NodeInternalIP && recordType == endpoint.RecordTypeAAAA) { addToEndpointMap(endpointMap, pod, domain, recordType, address.Address) } } } } func (ps *podSource) hostsFromTemplate(pod *corev1.Pod) (map[endpoint.EndpointKey][]string, error) { hosts, err := fqdn.ExecTemplate(ps.fqdnTemplate, pod) if err != nil { return nil, fmt.Errorf("skipping generating endpoints from template for pod %s: %w", pod.Name, err) } result := make(map[endpoint.EndpointKey][]string) for _, target := range hosts { for _, address := range pod.Status.PodIPs { if address.IP == "" { log.Debugf("skipping pod %q. PodIP is empty with phase %q", pod.Name, pod.Status.Phase) continue } key := endpoint.EndpointKey{ DNSName: target, RecordType: endpoint.SuitableType(address.IP), RecordTTL: annotations.TTLFromAnnotations(pod.Annotations, fmt.Sprintf("pod/%s", pod.Name)), } result[key] = append(result[key], address.IP) } } return result, nil } func addTargetsToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, targets []string, domainList ...string) { for _, domain := range domainList { for _, target := range targets { addToEndpointMap(endpointMap, pod, domain, endpoint.SuitableType(target), target) } } } func addToEndpointMap(endpointMap map[endpoint.EndpointKey][]string, pod *corev1.Pod, domain string, recordType string, address string) { key := endpoint.EndpointKey{ DNSName: domain, RecordType: recordType, RecordTTL: annotations.TTLFromAnnotations(pod.Annotations, fmt.Sprintf("pod/%s", pod.Name)), } if _, ok := endpointMap[key]; !ok { endpointMap[key] = []string{} } endpointMap[key] = append(endpointMap[key], address) } ================================================ FILE: source/pod_fqdn_test.go ================================================ /* 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 source import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" ) func TestNewPodSourceWithFqdn(t *testing.T) { for _, tt := range []struct { title string annotationFilter string fqdnTemplate string expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, } { t.Run(tt.title, func(t *testing.T) { _, err := NewPodSource( t.Context(), fake.NewClientset(), &Config{ FQDNTemplate: tt.fqdnTemplate, }) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } func TestPodSourceFqdnTemplatingExamples(t *testing.T) { for _, tt := range []struct { title string pods []*v1.Pod nodes []*v1.Node fqdnTemplate string expected []*endpoint.Endpoint combineFQDN bool sourceDomain string }{ { title: "templating expansion with multiple domains", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod-1", Namespace: "default", }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.domainA.com,{{ .Name }}.domainB.com", expected: []*endpoint.Endpoint{ {DNSName: "my-pod-1.domainA.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, {DNSName: "my-pod-1.domainB.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, }, }, { title: "templating expansion with multiple domains and fqdn combine and pod source domain", combineFQDN: true, sourceDomain: "example.org", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod-1", Namespace: "default", }, Spec: v1.PodSpec{ NodeName: "node-1.internal", }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, }, nodes: []*v1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "node-1.internal", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "10.1.192.139"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.domainA.com,{{ .Name }}.domainB.com", expected: []*endpoint.Endpoint{ {DNSName: "my-pod-1.domainA.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, {DNSName: "my-pod-1.domainB.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, {DNSName: "my-pod-1.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, }, }, { title: "templating with domain per namespace", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "default", }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "pod-2", Namespace: "kube-system", }, Status: v1.PodStatus{ PodIP: "100.67.94.102", PodIPs: []v1.PodIP{ {IP: "100.67.94.102"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.{{ .Namespace }}.example.org", expected: []*endpoint.Endpoint{ {DNSName: "pod-1.default.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, {DNSName: "pod-2.kube-system.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.102"}}, }, }, { title: "templating with pod and multiple ips for types A and AAAA", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "default", }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, {IP: "2041:0000:140F::875B:131B"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.example.org", expected: []*endpoint.Endpoint{ {DNSName: "pod-1.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, {DNSName: "pod-1.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2041:0000:140F::875B:131B"}}, }, }, { title: "templating with pod and target annotation that is currently not overriding target IPs", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "default", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "203.2.45.22", }, }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.example.org", expected: []*endpoint.Endpoint{ {DNSName: "pod-1.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, }, }, { title: "templating with pod and host annotation that is currently not overriding hostname", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "default", Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "ip-10-1-176-1.internal.domain.com", }, }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.example.org", expected: []*endpoint.Endpoint{ {DNSName: "pod-1.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, }, }, { title: "templating with simple annotation expansion", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "kube-system", Annotations: map[string]string{ "workload": "cluster-resources", }, }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "pod-2", Namespace: "workloads", Annotations: map[string]string{ "workload": "workloads", }, }, Status: v1.PodStatus{ PodIP: "100.67.94.102", PodIPs: []v1.PodIP{ {IP: "100.67.94.102"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.{{ .Annotations.workload }}.domain.tld", expected: []*endpoint.Endpoint{ {DNSName: "pod-1.cluster-resources.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, {DNSName: "pod-2.workloads.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.102"}}, }, }, { title: "templating with complex label expansion", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "kube-system", Labels: map[string]string{ "topology.kubernetes.io/region": "eu-west-1a", }, }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "pod-2", Namespace: "workloads", Labels: map[string]string{ "topology.kubernetes.io/region": "eu-west-1b", }, }, Status: v1.PodStatus{ PodIP: "100.67.94.102", PodIPs: []v1.PodIP{ {IP: "100.67.94.102"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.{{ index .ObjectMeta.Labels \"topology.kubernetes.io/region\" }}.domain.tld", expected: []*endpoint.Endpoint{ {DNSName: "pod-1.eu-west-1a.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, {DNSName: "pod-2.eu-west-1b.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.102"}}, }, }, { title: "templating with shared all domain", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "kube-system", }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, {IP: "100.67.94.102"}, {IP: "100.67.94.103"}, {IP: "2041:0000:140F::875B:131B"}, {IP: "::11.22.33.44"}, }, }, }, }, fqdnTemplate: "pods-all.domain.tld", expected: []*endpoint.Endpoint{ {DNSName: "pods-all.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101", "100.67.94.102", "100.67.94.103"}}, {DNSName: "pods-all.domain.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2041:0000:140F::875B:131B", "::11.22.33.44"}}, }, }, { title: "templating with fqdn template and IP not set as pod failed", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "kube-system", }, Status: v1.PodStatus{ Phase: v1.PodRunning, PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "pod-2", Namespace: "kube-system", }, Status: v1.PodStatus{ Phase: v1.PodFailed, }, }, }, fqdnTemplate: "{{ .Name }}.domain.tld", expected: []*endpoint.Endpoint{ {DNSName: "pod-1.domain.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, }, }, { title: "fqdn templating with label conditional and kind check", fqdnTemplate: `{{ if eq .Kind "Pod" }}{{ range $k, $v := .Labels }}{{ if and (contains $k "app") (contains $v "my-service-") }}{{ $.Name }}.{{ $v }}.pod.tld.org{{ printf "," }}{{ end }}{{ end }}{{ end }}`, expected: []*endpoint.Endpoint{ {DNSName: "pod-1.my-service-1.pod.tld.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.101"}}, {DNSName: "pod-2.my-service-2.pod.tld.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.67.94.102"}}, }, pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "kube-system", Labels: map[string]string{ "app1": "my-service-1", }, }, Status: v1.PodStatus{ Phase: v1.PodRunning, PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "pod-2", Namespace: "kube-system", Labels: map[string]string{ "app2": "my-service-2", }, }, Status: v1.PodStatus{ Phase: v1.PodRunning, PodIPs: []v1.PodIP{ {IP: "100.67.94.102"}, }, }, }, }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() for _, node := range tt.nodes { _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}) require.NoError(t, err) } for _, pod := range tt.pods { _, err := kubeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewPodSource( t.Context(), kubeClient, &Config{ FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: tt.combineFQDN, PodSourceDomain: tt.sourceDomain, }) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } func TestPodSourceFqdnTemplatingExamples_Failed(t *testing.T) { for _, tt := range []struct { title string pods []*v1.Pod nodes []*v1.Node fqdnTemplate string expected []*endpoint.Endpoint combineFQDN bool sourceDomain string }{ { title: "templating with fqdn template correct but value does not exist", pods: []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "pod-1", Namespace: "kube-system", }, Status: v1.PodStatus{ PodIP: "100.67.94.101", PodIPs: []v1.PodIP{ {IP: "100.67.94.101"}, }, }, }, }, fqdnTemplate: "{{ .Name }}.{{ .ThisNotExist }}.domain.tld", expected: []*endpoint.Endpoint{}, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() for _, node := range tt.nodes { _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}) require.NoError(t, err) } for _, pod := range tt.pods { _, err := kubeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewPodSource( t.Context(), kubeClient, &Config{ FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: tt.combineFQDN, PodSourceDomain: tt.sourceDomain, }) require.NoError(t, err) _, err = src.Endpoints(t.Context()) require.Error(t, err) }) } } ================================================ FILE: source/pod_indexer_test.go ================================================ /* 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 source import ( "fmt" "math/rand/v2" "net" "strconv" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/source/annotations" ) type podSpec struct { namespace string labels map[string]string annotations map[string]string // with labels and annotations totalTarget int // without provided labels and annotations totalRandom int } func fixtureCreatePodsWithNodes(input []podSpec) []*corev1.Pod { var pods []*corev1.Pod var createPod = func(index int, spec podSpec) *corev1.Pod { return &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("pod-%d-%s", index, uuid.NewString()), Namespace: spec.namespace, Labels: func() map[string]string { if spec.totalTarget > index { return spec.labels } return map[string]string{ "app": fmt.Sprintf("my-app-%d", rand.IntN(10)), "index": strconv.Itoa(index), } }(), Annotations: func() map[string]string { if spec.totalTarget > index { return spec.annotations } return map[string]string{ "key1": fmt.Sprintf("value-%d", rand.IntN(10)), } }(), }, Spec: corev1.PodSpec{}, Status: corev1.PodStatus{ Phase: corev1.PodRunning, PodIPs: []corev1.PodIP{ {IP: net.IPv4(192, byte(rand.IntN(250)), byte(rand.IntN(250)), byte(index)).String()}, }, }, } } for _, el := range input { totalPods := el.totalTarget + el.totalRandom for i := range totalPods { pods = append(pods, createPod(i, el)) } } for range 3 { rand.Shuffle(len(pods), func(i, j int) { pods[i], pods[j] = pods[j], pods[i] }) } // assign nodes to pods for i, pod := range pods { pod.Spec.NodeName = fmt.Sprintf("node-%d", i/5) // Assign 5 pods per node } return pods } func TestPodsWithAnnotationsAndLabels(t *testing.T) { // total target pods 700 // total random pods 3950 pods := fixtureCreatePodsWithNodes([]podSpec{ { namespace: "dev", labels: map[string]string{"app": "nginx", "env": "dev", "agent": "enabled"}, annotations: map[string]string{"arch": "amd64"}, totalTarget: 300, totalRandom: 700, }, { namespace: "prod", labels: map[string]string{"app": "nginx", "env": "prod", "agent": "enabled"}, annotations: map[string]string{"arch": "amd64"}, totalTarget: 150, totalRandom: 2700, }, { namespace: "default", labels: map[string]string{"app": "nginx", "agent": "disabled"}, annotations: map[string]string{"arch": "amd64"}, totalTarget: 250, totalRandom: 450, }, { namespace: "kube-system", labels: map[string]string{}, annotations: map[string]string{}, totalTarget: 0, totalRandom: 100, }, }) client := fake.NewClientset() nodes := map[string]bool{} for _, pod := range pods { if _, exists := nodes[pod.Spec.NodeName]; !exists { nodes[pod.Spec.NodeName] = true node := &corev1.Node{ ObjectMeta: metav1.ObjectMeta{ Name: pod.Spec.NodeName, }, } if _, err := client.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}); err != nil { assert.NoError(t, err) } } if _, err := client.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}); err != nil { assert.NoError(t, err) } } tests := []struct { name string namespace string labelSelector string annotationFilter string expectedEndpointCount int }{ { name: "prod namespace with labels", namespace: "prod", labelSelector: "app=nginx", expectedEndpointCount: 150, }, { name: "prod namespace with annotations", namespace: "prod", annotationFilter: "arch=amd64", expectedEndpointCount: 150, }, { name: "prod namespace with annotations and labels not exists", namespace: "prod", labelSelector: "app=not-exists", annotationFilter: "arch=amd64", expectedEndpointCount: 0, }, { name: "all namespaces with correct annotations and labels", namespace: "", labelSelector: "app=nginx,agent=enabled", annotationFilter: "arch=amd64", expectedEndpointCount: 450, // 300 from dev + 150 from prod }, { name: "all namespaces with loose annotations and labels", namespace: "", labelSelector: "app=nginx", annotationFilter: "arch=amd64", expectedEndpointCount: 700, // 300 from dev + 150 from prod + 250 from default }, { name: "all namespaces with loose annotations and labels", namespace: "", labelSelector: "agent", annotationFilter: "arch", expectedEndpointCount: 700, }, { name: "all namespaces without filters", namespace: "", labelSelector: "", annotationFilter: "", expectedEndpointCount: 4650, }, { name: "single namespace without filters", namespace: "default", labelSelector: "", annotationFilter: "", expectedEndpointCount: 700, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { selector, _ := annotations.ParseFilter(tt.labelSelector) pSource, err := NewPodSource( t.Context(), client, &Config{ Namespace: tt.namespace, FQDNTemplate: "{{ .Name }}.tld.org", AnnotationFilter: tt.annotationFilter, LabelFilter: selector, }) require.NoError(t, err) endpoints, err := pSource.Endpoints(t.Context()) require.NoError(t, err) assert.Len(t, endpoints, tt.expectedEndpointCount) }) } } ================================================ FILE: source/pod_test.go ================================================ /* Copyright 2021 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 source import ( "fmt" "math/rand" "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" corev1lister "k8s.io/client-go/listers/core/v1" "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/source/annotations" "k8s.io/client-go/kubernetes/fake" ) // testPodSource tests that various services generate the correct endpoints. func TestPodSource(t *testing.T) { t.Parallel() for _, tc := range []struct { title string targetNamespace string compatibility string ignoreNonHostNetworkPods bool PodSourceDomain string expected []*endpoint.Endpoint expectError bool nodes []*corev1.Node pods []*corev1.Pod }{ { "create IPv4 records based on pod's external and internal IPs", "", "", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}, RecordType: endpoint.RecordTypeA}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "10.0.1.2", }, }, }, }, { "create IPv4 records based on pod's external and internal IPs using DNS Controller annotations", "", "kops-dns-controller", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}, RecordType: endpoint.RecordTypeA}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.a.foo.example.org", kopsDNSControllerHostnameAnnotationKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.a.foo.example.org", kopsDNSControllerHostnameAnnotationKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "10.0.1.2", }, }, }, }, { "create IPv6 records based on pod's external and internal IPs", "", "", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}, RecordType: endpoint.RecordTypeAAAA}, {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}, RecordType: endpoint.RecordTypeAAAA}, }, false, nodesFixturesIPv6(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "2001:DB8::1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "2001:DB8::2", }, }, }, }, { "create IPv6 records based on pod's external and internal IPs using DNS Controller annotations", "", "kops-dns-controller", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}, RecordType: endpoint.RecordTypeAAAA}, {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}, RecordType: endpoint.RecordTypeAAAA}, }, false, nodesFixturesIPv6(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.a.foo.example.org", kopsDNSControllerHostnameAnnotationKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "2001:DB8::1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.a.foo.example.org", kopsDNSControllerHostnameAnnotationKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "2001:DB8::2", }, }, }, }, { "create records based on pod's target annotation", "", "", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"208.1.2.1", "208.1.2.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"208.1.2.1", "208.1.2.2"}, RecordType: endpoint.RecordTypeA}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", annotations.TargetKey: "208.1.2.1", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", annotations.TargetKey: "208.1.2.2", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "10.0.1.2", }, }, }, }, { "create multiple records", "", "", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(5400)}, {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"2001:DB8::1"}, RecordType: endpoint.RecordTypeAAAA, RecordTTL: endpoint.TTL(5400)}, {DNSName: "b.foo.example.org", Targets: endpoint.Targets{"54.10.11.2"}, RecordType: endpoint.RecordTypeA}, }, false, []*corev1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-node1", }, Status: corev1.NodeStatus{ Addresses: []corev1.NodeAddress{ {Type: corev1.NodeExternalIP, Address: "54.10.11.1"}, {Type: corev1.NodeInternalIP, Address: "2001:DB8::1"}, {Type: corev1.NodeInternalIP, Address: "10.0.1.1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-node2", }, Status: corev1.NodeStatus{ Addresses: []corev1.NodeAddress{ {Type: corev1.NodeExternalIP, Address: "54.10.11.2"}, {Type: corev1.NodeInternalIP, Address: "10.0.1.2"}, }, }, }, }, []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "a.foo.example.org", annotations.TtlKey: "1h30m", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "b.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "10.0.1.2", }, }, }, }, { "pods with hostNetwore=false should be ignored", "", "", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(1)}, {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"10.0.1.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(1)}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", annotations.TtlKey: "1s", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: false, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "100.0.1.2", }, }, }, }, { "only watch a given namespace", "kube-system", "", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA}, {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"10.0.1.1"}, RecordType: endpoint.RecordTypeA}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "default", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", annotations.TtlKey: "1s", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "100.0.1.2", }, }, }, }, { "split record for internal hostname annotation", "", "", true, "", []*endpoint.Endpoint{ {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"10.0.1.1"}, RecordType: endpoint.RecordTypeA}, {DNSName: "internal.b.foo.example.org", Targets: endpoint.Targets{"10.0.1.1"}, RecordType: endpoint.RecordTypeA}, }, false, []*corev1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-node1", }, Status: corev1.NodeStatus{ Addresses: []corev1.NodeAddress{ {Type: corev1.NodeInternalIP, Address: "10.0.1.1"}, }, }, }, }, []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org,internal.b.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, }, }, { "create IPv4 records for non-host network pods", "", "", false, "example.org", []*endpoint.Endpoint{ {DNSName: "my-pod1.example.org", Targets: endpoint.Targets{"192.168.1.1"}, RecordType: endpoint.RecordTypeA, RecordTTL: endpoint.TTL(60)}, {DNSName: "my-pod2.example.org", Targets: endpoint.Targets{"192.168.1.2"}, RecordType: endpoint.RecordTypeA}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.TtlKey: "1m", }, }, Spec: corev1.PodSpec{ HostNetwork: false, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "192.168.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{}, }, Spec: corev1.PodSpec{ HostNetwork: false, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "192.168.1.2", }, }, }, }, { "create records based on internal hostname annotation for non-host network pod", "", "", false, "", []*endpoint.Endpoint{ {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"192.168.1.1"}, RecordType: endpoint.RecordTypeA}, }, false, nil, []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: false, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "192.168.1.1", }, }, }, }, { "create records based on internal hostname annotation for host network pod", "", "", false, "", []*endpoint.Endpoint{ {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"192.168.1.1"}, RecordType: endpoint.RecordTypeA}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "192.168.1.1", }, }, }, }, { "create records based on pod's target annotation with pod source domain", "", "", true, "example.org", []*endpoint.Endpoint{ {DNSName: "my-pod1.example.org", Targets: endpoint.Targets{"208.1.2.1"}, RecordType: endpoint.RecordTypeA}, {DNSName: "my-pod2.example.org", Targets: endpoint.Targets{"208.1.2.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"208.1.2.1", "208.1.2.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "internal.a.foo.example.org", Targets: endpoint.Targets{"208.1.2.1", "208.1.2.2"}, RecordType: endpoint.RecordTypeA}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", annotations.TargetKey: "208.1.2.1", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod2", Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", annotations.TargetKey: "208.1.2.2", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "10.0.1.2", }, }, }, }, { "host network pod on a missing node", "", "", true, "", []*endpoint.Endpoint{}, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "missing-node", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, }, }, { "provider-specific annotation is not supported and is ignored", "", "", true, "", []*endpoint.Endpoint{ {DNSName: "a.foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA}, }, false, nodesFixturesIPv4(), []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-pod1", Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "a.foo.example.org", annotations.AWSPrefix + "weight": "10", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, }, }, } { t.Run(tc.title, func(t *testing.T) { kubernetes := fake.NewClientset() ctx := t.Context() // Create the nodes for _, node := range tc.nodes { if _, err := kubernetes.CoreV1().Nodes().Create(ctx, node, metav1.CreateOptions{}); err != nil { t.Fatal(err) } } // Create the pods for _, pod := range tc.pods { pods := kubernetes.CoreV1().Pods(pod.Namespace) if _, err := pods.Create(ctx, pod, metav1.CreateOptions{}); err != nil { t.Fatal(err) } } client, err := NewPodSource(ctx, kubernetes, &Config{ Namespace: tc.targetNamespace, Compatibility: tc.compatibility, IgnoreNonHostNetworkPods: tc.ignoreNonHostNetworkPods, PodSourceDomain: tc.PodSourceDomain, }) require.NoError(t, err) endpoints, err := client.Endpoints(ctx) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) for _, ep := range endpoints { // TODO: source should always set the resource label key. currently not supported by the pod source. require.Empty(t, ep.Labels, "Labels should not be empty for endpoint %s", ep.DNSName) require.NotContains(t, ep.Labels, endpoint.ResourceLabelKey) } }) } } func TestPodSourceLogs(t *testing.T) { t.Parallel() // Generate unique pod names to avoid log conflicts across parallel tests. // Since logs are globally shared, using the same pod names could cause // false positives in unexpectedDebugLogs assertions. suffix := fmt.Sprintf("%d", rand.Intn(100000)) for _, tc := range []struct { title string ignoreNonHostNetworkPods bool pods []*corev1.Pod nodes []*corev1.Node expectedDebugLogs []string unexpectedDebugLogs []string }{ { "pods with hostNetwore=false should be skipped logging", true, []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("my-pod1-%s", suffix), Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: false, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "100.0.1.1", }, }, }, nodesFixturesIPv4(), []string{fmt.Sprintf("skipping pod my-pod1-%s. hostNetwork=false", suffix)}, nil, }, { "host network pod on a missing node", true, []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("missing-node-pod-%s", suffix), Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "missing-node", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, }, nodesFixturesIPv4(), []string{ fmt.Sprintf(`Get node[missing-node] of pod[missing-node-pod-%s] error: node "missing-node" not found; ignoring`, suffix), }, nil, }, { "mixed valid and hostNetwork=false pods with missing node", true, []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("valid-pod-%s", suffix), Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "valid.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("non-hostnet-pod-%s", suffix), Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "nonhost.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: false, NodeName: "my-node2", }, Status: corev1.PodStatus{ PodIP: "100.0.1.2", }, }, { ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("missing-node-pod-%s", suffix), Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "missing.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "missing-node", }, Status: corev1.PodStatus{ PodIP: "10.0.1.3", }, }, }, nodesFixturesIPv4(), []string{ fmt.Sprintf("skipping pod non-hostnet-pod-%s. hostNetwork=false", suffix), fmt.Sprintf(`Get node[missing-node] of pod[missing-node-pod-%s] error: node "missing-node" not found; ignoring`, suffix), }, []string{ fmt.Sprintf("skipping pod valid-pod-%s. hostNetwork=false", suffix), fmt.Sprintf(`Get node[my-node1] of pod[valid-pod-%s] error: node "my-node1" not found; ignoring`, suffix), }, }, { "valid pods with hostNetwork=true should not generate logs", true, []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("valid-pod-%s", suffix), Namespace: "kube-system", Annotations: map[string]string{ annotations.HostnameKey: "valid.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: true, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, }, nodesFixturesIPv4(), nil, []string{ fmt.Sprintf("skipping pod valid-pod-%s. hostNetwork=false", suffix), fmt.Sprintf(`Get node[my-node1] of pod[valid-pod-%s] error: node "my-node1" not found; ignoring`, suffix), }, }, { "when ignoreNonHostNetworkPods=false, no skip logs should be generated", false, []*corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("my-pod1-%s", suffix), Namespace: "kube-system", Annotations: map[string]string{ annotations.InternalHostnameKey: "internal.a.foo.example.org", annotations.HostnameKey: "a.foo.example.org", }, }, Spec: corev1.PodSpec{ HostNetwork: false, NodeName: "my-node1", }, Status: corev1.PodStatus{ PodIP: "10.0.1.1", }, }, }, nodesFixturesIPv4(), nil, []string{ fmt.Sprintf("skipping pod my-pod1-%s. hostNetwork=false", suffix), }, }, } { t.Run(tc.title, func(t *testing.T) { kubernetes := fake.NewClientset() ctx := t.Context() // Create the nodes for _, node := range tc.nodes { if _, err := kubernetes.CoreV1().Nodes().Create(ctx, node, metav1.CreateOptions{}); err != nil { t.Fatal(err) } } // Create the pods for _, pod := range tc.pods { pods := kubernetes.CoreV1().Pods(pod.Namespace) if _, err := pods.Create(ctx, pod, metav1.CreateOptions{}); err != nil { t.Fatal(err) } } src, err := NewPodSource(ctx, kubernetes, &Config{ FQDNTemplate: "", IgnoreNonHostNetworkPods: tc.ignoreNonHostNetworkPods, }) require.NoError(t, err) hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) _, err = src.Endpoints(ctx) require.NoError(t, err) // Check if all expected logs are present in actual logs. // We don't do an exact match because logs are globally shared, // making precise comparisons difficult for _, expectedLog := range tc.expectedDebugLogs { logtest.TestHelperLogContains(expectedLog, hook, t) } // Check that no unexpected logs are present. // This ensures that logs are not generated inappropriately. for _, unexpectedLog := range tc.unexpectedDebugLogs { logtest.TestHelperLogNotContains(unexpectedLog, hook, t) } }) } } func TestPodSource_AddEventHandler(t *testing.T) { fakeInformer := new(fakePodInformer) inf := testInformer{} fakeInformer.On("Informer").Return(&inf) pSource := &podSource{ podInformer: fakeInformer, } handlerCalled := false handler := func() { handlerCalled = true } pSource.AddEventHandler(t.Context(), handler) fakeInformer.AssertNumberOfCalls(t, "Informer", 1) assert.False(t, handlerCalled) assert.Equal(t, 1, inf.times) } type fakePodInformer struct { mock.Mock } func (f *fakePodInformer) Informer() cache.SharedIndexInformer { args := f.Called() return args.Get(0).(cache.SharedIndexInformer) } func (f *fakePodInformer) Lister() corev1lister.PodLister { return corev1lister.NewPodLister(f.Informer().GetIndexer()) } func nodesFixturesIPv6() []*corev1.Node { return []*corev1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-node1", }, Status: corev1.NodeStatus{ Addresses: []corev1.NodeAddress{ {Type: corev1.NodeInternalIP, Address: "2001:DB8::1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-node2", }, Status: corev1.NodeStatus{ Addresses: []corev1.NodeAddress{ {Type: corev1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, } } func nodesFixturesIPv4() []*corev1.Node { return []*corev1.Node{ { ObjectMeta: metav1.ObjectMeta{ Name: "my-node1", }, Status: corev1.NodeStatus{ Addresses: []corev1.NodeAddress{ {Type: corev1.NodeExternalIP, Address: "54.10.11.1"}, {Type: corev1.NodeInternalIP, Address: "10.0.1.1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "my-node2", }, Status: corev1.NodeStatus{ Addresses: []corev1.NodeAddress{ {Type: corev1.NodeExternalIP, Address: "54.10.11.2"}, {Type: corev1.NodeInternalIP, Address: "10.0.1.2"}, }, }, }, } } func TestPodTransformerInPodSource(t *testing.T) { t.Run("transformer set", func(t *testing.T) { ctx := t.Context() fakeClient := fake.NewClientset() pod := &v1.Pod{ Spec: v1.PodSpec{ Containers: []v1.Container{{ Name: "test", }}, Hostname: "test-hostname", NodeName: "test-node", HostNetwork: true, }, ObjectMeta: metav1.ObjectMeta{ Namespace: "test-ns", Name: "test-name", Labels: map[string]string{ "label1": "value1", "label2": "value2", "label3": "value3", }, Annotations: map[string]string{ "user-annotation": "value", "external-dns.alpha.kubernetes.io/hostname": "test-hostname", "external-dns.alpha.kubernetes.io/random": "value", "other/annotation": "value", }, UID: "someuid", }, Status: v1.PodStatus{ PodIP: "127.0.0.1", HostIP: "127.0.0.2", Conditions: []v1.PodCondition{{ Type: v1.PodReady, Status: v1.ConditionTrue, }, { Type: v1.ContainersReady, Status: v1.ConditionFalse, }}, }, } _, err := fakeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) // Should not error when creating the source src, err := NewPodSource(ctx, fakeClient, &Config{}) require.NoError(t, err) ps, ok := src.(*podSource) require.True(t, ok) retrieved, err := ps.podInformer.Lister().Pods("test-ns").Get("test-name") require.NoError(t, err) // Metadata assert.Equal(t, "test-name", retrieved.Name) assert.Equal(t, "test-ns", retrieved.Namespace) assert.NotEmpty(t, retrieved.UID) assert.Empty(t, retrieved.Labels) // Filtered assert.Equal(t, map[string]string{ "user-annotation": "value", "external-dns.alpha.kubernetes.io/hostname": "test-hostname", "external-dns.alpha.kubernetes.io/random": "value", "other/annotation": "value", }, retrieved.Annotations) // Spec assert.Empty(t, retrieved.Spec.Containers) assert.Empty(t, retrieved.Spec.Hostname) assert.Equal(t, "test-node", retrieved.Spec.NodeName) assert.True(t, retrieved.Spec.HostNetwork) // Status assert.Empty(t, retrieved.Status.ContainerStatuses) assert.Empty(t, retrieved.Status.InitContainerStatuses) assert.Empty(t, retrieved.Status.HostIP) assert.Equal(t, "127.0.0.1", retrieved.Status.PodIP) assert.Empty(t, retrieved.Status.Conditions) }) t.Run("transformer is not used when fqdnTemplate is set", func(t *testing.T) { fakeClient := fake.NewClientset() pod := &v1.Pod{ Spec: v1.PodSpec{ Containers: []v1.Container{{ Name: "test", }}, Hostname: "test-hostname", NodeName: "test-node", HostNetwork: true, }, ObjectMeta: metav1.ObjectMeta{ Namespace: "test-ns", Name: "test-name", Labels: map[string]string{ "label1": "value1", "label2": "value2", "label3": "value3", }, Annotations: map[string]string{ "user-annotation": "value", "external-dns.alpha.kubernetes.io/hostname": "test-hostname", "external-dns.alpha.kubernetes.io/random": "value", "other/annotation": "value", }, UID: "someuid", }, Status: v1.PodStatus{ PodIP: "127.0.0.1", HostIP: "127.0.0.2", Conditions: []v1.PodCondition{{ Type: v1.PodReady, Status: v1.ConditionTrue, }, { Type: v1.ContainersReady, Status: v1.ConditionFalse, }}, }, } _, err := fakeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) // Should not error when creating the source src, err := NewPodSource(t.Context(), fakeClient, &Config{ FQDNTemplate: "template", }) require.NoError(t, err) ps, ok := src.(*podSource) require.True(t, ok) retrieved, err := ps.podInformer.Lister().Pods("test-ns").Get("test-name") require.NoError(t, err) // Metadata assert.Equal(t, "test-name", retrieved.Name) assert.Equal(t, "test-ns", retrieved.Namespace) assert.NotEmpty(t, retrieved.UID) assert.NotEmpty(t, retrieved.Labels) }) } func TestProcessEndpoint_Pod_RefObjectExist(t *testing.T) { elements := []runtime.Object{ &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Namespace: "01", Name: "foo", Annotations: map[string]string{ annotations.HostnameKey: "foo.example.com", annotations.TargetKey: "1.2.3", }, UID: "uid-1", }, }, &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Namespace: "02", Name: "bar", Annotations: map[string]string{ annotations.HostnameKey: "bar.example.com", annotations.TargetKey: "3.4.5", }, UID: "uid-2", }, }, } fakeClient := fake.NewClientset(elements...) client, err := NewPodSource( t.Context(), fakeClient, &Config{}, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) require.NoError(t, err) testutils.AssertEndpointsHaveRefObject(t, endpoints, types.Pod, len(elements)) } ================================================ FILE: source/service.go ================================================ /* Copyright 2017 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 source import ( "context" "fmt" "maps" "net" "slices" "sort" "strings" "text/template" log "github.com/sirupsen/logrus" v1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" apitypes "k8s.io/apimachinery/pkg/types" kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" discoveryinformers "k8s.io/client-go/informers/discovery/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/provider" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" "sigs.k8s.io/external-dns/source/types" ) var ( knownServiceTypes = map[v1.ServiceType]struct{}{ v1.ServiceTypeClusterIP: {}, // Default service type exposes the service on a cluster-internal IP. v1.ServiceTypeNodePort: {}, // Exposes the service on each node's IP at a static port. v1.ServiceTypeLoadBalancer: {}, // Exposes the service externally using a cloud provider's load balancer. v1.ServiceTypeExternalName: {}, // Maps the service to an external DNS name. } serviceNameIndexKey = "serviceName" ) // serviceSource is an implementation of Source for Kubernetes service objects. // It will find all services that are under our jurisdiction, i.e. annotated // desired hostname and matching or no controller annotation. For each of the // matched services' entrypoints it will return a corresponding // Endpoint object. // +externaldns:source:name=service // +externaldns:source:category=Kubernetes Core // +externaldns:source:description=Creates DNS entries based on Kubernetes Service resources // +externaldns:source:resources=Service // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true // +externaldns:source:events=true type serviceSource struct { client kubernetes.Interface namespace string annotationFilter string labelSelector labels.Selector fqdnTemplate *template.Template combineFQDNAnnotation bool ignoreHostnameAnnotation bool publishInternal bool publishHostIP bool alwaysPublishNotReadyAddresses bool resolveLoadBalancerHostname bool listenEndpointEvents bool serviceInformer coreinformers.ServiceInformer endpointSlicesInformer discoveryinformers.EndpointSliceInformer podInformer coreinformers.PodInformer nodeInformer coreinformers.NodeInformer serviceTypeFilter *serviceTypes exposeInternalIPv6 bool excludeUnschedulable bool // process Services with legacy annotations compatibility string } // NewServiceSource creates a new serviceSource with the given config. func NewServiceSource( ctx context.Context, kubeClient kubernetes.Interface, config *Config, ) (Source, error) { tmpl, err := fqdn.ParseTemplate(config.FQDNTemplate) if err != nil { return nil, err } namespace := config.Namespace // Use shared informers to listen for add/update/delete of services/pods/nodes in the specified namespace. // Set the resync period to 0 to prevent processing when nothing has changed informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace)) serviceInformer := informerFactory.Core().V1().Services() // Add default resource event handlers to properly initialize informer. _, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) // Transform the slice into a map so it will be way much easier and fast to filter later sTypesFilter, err := newServiceTypesFilter(config.ServiceTypeFilter) if err != nil { return nil, err } var endpointSlicesInformer discoveryinformers.EndpointSliceInformer var podInformer coreinformers.PodInformer if sTypesFilter.isRequired(v1.ServiceTypeNodePort, v1.ServiceTypeClusterIP) { endpointSlicesInformer = informerFactory.Discovery().V1().EndpointSlices() podInformer = informerFactory.Core().V1().Pods() _, _ = endpointSlicesInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = podInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) // TODO: move to shared indexer in informer package // Add an indexer to the EndpointSlice informer to index by the service name label err = endpointSlicesInformer.Informer().AddIndexers(cache.Indexers{ serviceNameIndexKey: func(obj any) ([]string, error) { endpointSlice, ok := obj.(*discoveryv1.EndpointSlice) if !ok { // This should never happen because the Informer should only contain EndpointSlice objects return nil, fmt.Errorf("expected %T but got %T instead", endpointSlice, obj) } serviceName := endpointSlice.Labels[discoveryv1.LabelServiceName] if serviceName == "" { return nil, nil } key := apitypes.NamespacedName{Namespace: endpointSlice.Namespace, Name: serviceName}.String() return []string{key}, nil }, }) if err != nil { return nil, err } // TODO: move to shared transformer in informer package // Transformer is used to reduce the memory usage of the informer. // The pod informer will otherwise store a full in-memory, go-typed copy of all pod schemas in the cluster. // If watchList is not used it will not prevent memory bursts on the initial informer sync. _ = podInformer.Informer().SetTransform(func(i any) (any, error) { pod, ok := i.(*v1.Pod) if !ok { return nil, fmt.Errorf("object is not a pod") } if pod.UID == "" { // Pod was already transformed and we must be idempotent. return pod, nil } // All pod level annotations we're interested in start with a common prefix podAnnotations := map[string]string{} for key, value := range pod.Annotations { if strings.HasPrefix(key, annotations.AnnotationKeyPrefix) { podAnnotations[key] = value } } return &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ // Name/namespace must always be kept for the informer to work. Name: pod.Name, Namespace: pod.Namespace, // Used to match services. Labels: pod.Labels, Annotations: podAnnotations, DeletionTimestamp: pod.DeletionTimestamp, }, Spec: v1.PodSpec{ Hostname: pod.Spec.Hostname, NodeName: pod.Spec.NodeName, }, Status: v1.PodStatus{ HostIP: pod.Status.HostIP, Phase: pod.Status.Phase, Conditions: pod.Status.Conditions, }, }, nil }) } var nodeInformer coreinformers.NodeInformer if sTypesFilter.isRequired(v1.ServiceTypeNodePort) { nodeInformer = informerFactory.Core().V1().Nodes() _, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) } informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForCacheSync(ctx, informerFactory); err != nil { return nil, err } return &serviceSource{ client: kubeClient, namespace: namespace, annotationFilter: config.AnnotationFilter, compatibility: config.Compatibility, fqdnTemplate: tmpl, combineFQDNAnnotation: config.CombineFQDNAndAnnotation, ignoreHostnameAnnotation: config.IgnoreHostnameAnnotation, publishInternal: config.PublishInternal, publishHostIP: config.PublishHostIP, alwaysPublishNotReadyAddresses: config.AlwaysPublishNotReadyAddresses, serviceInformer: serviceInformer, endpointSlicesInformer: endpointSlicesInformer, podInformer: podInformer, nodeInformer: nodeInformer, serviceTypeFilter: sTypesFilter, labelSelector: config.LabelFilter, resolveLoadBalancerHostname: config.ResolveLoadBalancerHostname, listenEndpointEvents: config.ListenEndpointEvents, exposeInternalIPv6: config.ExposeInternalIPv6, excludeUnschedulable: config.ExcludeUnschedulable, }, nil } // Endpoints return endpoint objects for each service that should be processed. func (sc *serviceSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { services, err := sc.serviceInformer.Lister().Services(sc.namespace).List(sc.labelSelector) if err != nil { return nil, err } // filter on service types if at least one has been provided services = sc.filterByServiceType(services) services, err = annotations.Filter(services, sc.annotationFilter) if err != nil { return nil, err } endpoints := make([]*endpoint.Endpoint, 0) for _, svc := range services { if annotations.IsControllerMismatch(svc, types.Service) { continue } svcEndpoints := sc.endpoints(svc) // process legacy annotations if no endpoints were returned and compatibility mode is enabled. if len(svcEndpoints) == 0 && sc.compatibility != "" { svcEndpoints, err = legacyEndpointsFromService(svc, sc) if err != nil { return nil, err } } // apply template if none of the above is found svcEndpoints, err = fqdn.CombineWithTemplatedEndpoints( svcEndpoints, sc.fqdnTemplate, sc.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(svc) }, ) if err != nil { return nil, err } if endpoint.HasNoEmptyEndpoints(svcEndpoints, types.Service, svc) { continue } endpoint.AttachRefObject(svcEndpoints, events.NewObjectReference(svc, types.Service)) log.Debugf("Endpoints generated from service: %s/%s: %v", svc.Namespace, svc.Name, svcEndpoints) endpoints = append(endpoints, svcEndpoints...) } // this sorting is required to make merging work. // after we merge endpoints that have same DNS, we want to ensure that we end up with the same service being an "owner" // of all those records, as otherwise each time we update, we will end up with a different service that gets data merged in // and that will cause external-dns to recreate dns record due to different service owner in TXT record. // if new service is added or old one removed, that might cause existing record to get re-created due to potentially new // owner being selected. Which is fine, since it shouldn't happen often and shouldn't cause any disruption. if len(endpoints) > 1 { sort.Slice(endpoints, func(i, j int) bool { return endpoints[i].Labels[endpoint.ResourceLabelKey] < endpoints[j].Labels[endpoint.ResourceLabelKey] }) mergedEndpoints := make(map[endpoint.EndpointKey][]*endpoint.Endpoint) for _, ep := range endpoints { key := ep.Key() if existing, ok := mergedEndpoints[key]; ok { if existing[0].RecordType == endpoint.RecordTypeCNAME { log.Debugf("CNAME %s with multiple targets found", ep.DNSName) mergedEndpoints[key] = append(existing, ep) continue } existing[0].Targets = append(existing[0].Targets, ep.Targets...) existing[0].Targets = endpoint.NewTargets(existing[0].Targets...) mergedEndpoints[key] = existing } else { ep.Targets = endpoint.NewTargets(ep.Targets...) mergedEndpoints[key] = []*endpoint.Endpoint{ep} } } processed := make([]*endpoint.Endpoint, 0, len(mergedEndpoints)) for _, ep := range mergedEndpoints { processed = append(processed, ep...) } endpoints = processed // Use stable sort to not disrupt the order of services sort.SliceStable(endpoints, func(i, j int) bool { if endpoints[i].DNSName != endpoints[j].DNSName { return endpoints[i].DNSName < endpoints[j].DNSName } return endpoints[i].RecordType < endpoints[j].RecordType }) } return MergeEndpoints(endpoints), nil } // extractHeadlessEndpoints extracts endpoints from a headless service using the "Endpoints" Kubernetes API resource func (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname string, ttl endpoint.TTL) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint selector, err := annotations.ParseFilter(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String()) if err != nil { return nil } serviceKey := cache.ObjectName{Namespace: svc.Namespace, Name: svc.Name}.String() rawEndpointSlices, err := sc.endpointSlicesInformer.Informer().GetIndexer().ByIndex(serviceNameIndexKey, serviceKey) if err != nil { log.Errorf("Get EndpointSlices of service[%s] error:%v", svc.GetName(), err) return nil } endpointSlices := convertToEndpointSlices(rawEndpointSlices) pods, err := sc.podInformer.Lister().Pods(svc.Namespace).List(selector) if err != nil { log.Errorf("List Pods of service[%s] error:%v", svc.GetName(), err) return endpoints } endpointsType := getEndpointsTypeFromAnnotations(svc.Annotations) publishPodIPs := endpointsType != EndpointsTypeNodeExternalIP && endpointsType != EndpointsTypeHostIP && !sc.publishHostIP publishNotReadyAddresses := svc.Spec.PublishNotReadyAddresses || sc.alwaysPublishNotReadyAddresses targetsByHeadlessDomainAndType := sc.processHeadlessEndpointsFromSlices( pods, endpointSlices, hostname, endpointsType, publishPodIPs, publishNotReadyAddresses) endpoints = buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, ttl) return endpoints } // Helper to convert raw objects to EndpointSlice func convertToEndpointSlices(rawEndpointSlices []any) []*discoveryv1.EndpointSlice { endpointSlices := make([]*discoveryv1.EndpointSlice, 0, len(rawEndpointSlices)) for _, obj := range rawEndpointSlices { endpointSlice, ok := obj.(*discoveryv1.EndpointSlice) if !ok { log.Errorf("Expected EndpointSlice but got %T instead, skipping", obj) continue } endpointSlices = append(endpointSlices, endpointSlice) } return endpointSlices } // processHeadlessEndpointsFromSlices processes EndpointSlices specifically for headless services // and returns deduped targets by domain/type. // TODO: Consider refactoring with generics when available: https://github.com/kubernetes/kubernetes/issues/133544 func (sc *serviceSource) processHeadlessEndpointsFromSlices( pods []*v1.Pod, endpointSlices []*discoveryv1.EndpointSlice, hostname string, endpointsType string, publishPodIPs bool, publishNotReadyAddresses bool, ) map[endpoint.EndpointKey]endpoint.Targets { targetsByHeadlessDomainAndType := make(map[endpoint.EndpointKey]endpoint.Targets) for _, endpointSlice := range endpointSlices { for _, ep := range endpointSlice.Endpoints { if !conditionToBool(ep.Conditions.Ready) && !publishNotReadyAddresses { continue } if publishPodIPs && endpointSlice.AddressType != discoveryv1.AddressTypeIPv4 && endpointSlice.AddressType != discoveryv1.AddressTypeIPv6 { log.Debugf("Skipping EndpointSlice %s/%s because its address type is unsupported: %s", endpointSlice.Namespace, endpointSlice.Name, endpointSlice.AddressType) continue } pod := findPodForEndpoint(ep, pods) if pod == nil { if ep.TargetRef != nil { log.Errorf("Pod %s not found for address %v", ep.TargetRef.Name, ep) } else { log.Errorf("Pod not found for endpoint with nil TargetRef: %v", ep) } continue } headlessDomains := []string{hostname} if pod.Spec.Hostname != "" { headlessDomains = append(headlessDomains, fmt.Sprintf("%s.%s", pod.Spec.Hostname, hostname)) } for _, headlessDomain := range headlessDomains { targets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain) for _, target := range targets { key := endpoint.EndpointKey{ DNSName: headlessDomain, RecordType: endpoint.SuitableType(target), } targetsByHeadlessDomainAndType[key] = append(targetsByHeadlessDomainAndType[key], target) } } } } // Return a copy of the map to prevent external modifications result := make(map[endpoint.EndpointKey]endpoint.Targets, len(targetsByHeadlessDomainAndType)) for k, v := range targetsByHeadlessDomainAndType { result[k] = append(endpoint.Targets(nil), v...) } return result } // Helper to find pod for endpoint func findPodForEndpoint(ep discoveryv1.Endpoint, pods []*v1.Pod) *v1.Pod { if ep.TargetRef == nil || ep.TargetRef.APIVersion != "" || ep.TargetRef.Kind != "Pod" { log.Debugf("Skipping address because its target is not a pod: %v", ep) return nil } for _, v := range pods { if v.Name == ep.TargetRef.Name { return v } } return nil } // Helper to get targets for domain func (sc *serviceSource) getTargetsForDomain( pod *v1.Pod, ep discoveryv1.Endpoint, endpointSlice *discoveryv1.EndpointSlice, endpointsType, headlessDomain string) endpoint.Targets { targets := annotations.TargetsFromTargetAnnotation(pod.Annotations) if len(targets) == 0 { switch { case endpointsType == EndpointsTypeNodeExternalIP: if sc.nodeInformer == nil { log.Warnf("Skipping EndpointSlice %s/%s as --service-type-filter disable node informer", endpointSlice.Namespace, endpointSlice.Name) return nil } node, err := sc.nodeInformer.Lister().Get(pod.Spec.NodeName) if err != nil { log.Errorf("Get node[%s] of pod[%s] error: %v; not adding any NodeExternalIP endpoints", pod.Spec.NodeName, pod.GetName(), err) return nil } for _, address := range node.Status.Addresses { if address.Type == v1.NodeExternalIP || (sc.exposeInternalIPv6 && address.Type == v1.NodeInternalIP && endpoint.SuitableType(address.Address) == endpoint.RecordTypeAAAA) { targets = append(targets, address.Address) log.Debugf("Generating matching endpoint %s with NodeExternalIP %s", headlessDomain, address.Address) } } case endpointsType == EndpointsTypeHostIP || sc.publishHostIP: targets = endpoint.Targets{pod.Status.HostIP} log.Debugf("Generating matching endpoint %s with HostIP %s", headlessDomain, pod.Status.HostIP) default: if len(ep.Addresses) == 0 { log.Warnf("EndpointSlice %s/%s has no addresses for endpoint %v", endpointSlice.Namespace, endpointSlice.Name, ep) return nil } address := ep.Addresses[0] targets = endpoint.Targets{address} log.Debugf("Generating matching endpoint %s with EndpointSliceAddress IP %s", headlessDomain, address) } } return targets } // Helper to build endpoints from deduped targets func buildHeadlessEndpoints(svc *v1.Service, targetsByHeadlessDomainAndType map[endpoint.EndpointKey]endpoint.Targets, ttl endpoint.TTL) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint headlessKeys := make([]endpoint.EndpointKey, 0, len(targetsByHeadlessDomainAndType)) for headlessKey := range targetsByHeadlessDomainAndType { headlessKeys = append(headlessKeys, headlessKey) } sort.Slice(headlessKeys, func(i, j int) bool { if headlessKeys[i].DNSName != headlessKeys[j].DNSName { return headlessKeys[i].DNSName < headlessKeys[j].DNSName } return headlessKeys[i].RecordType < headlessKeys[j].RecordType }) for _, headlessKey := range headlessKeys { allTargets := targetsByHeadlessDomainAndType[headlessKey] targets := []string{} deduppedTargets := map[string]struct{}{} for _, target := range allTargets { if _, ok := deduppedTargets[target]; ok { log.Debugf("Removing duplicate target %s", target) continue } deduppedTargets[target] = struct{}{} targets = append(targets, target) } var ep *endpoint.Endpoint if ttl.IsConfigured() { ep = endpoint.NewEndpointWithTTL(headlessKey.DNSName, headlessKey.RecordType, ttl, targets...) } else { ep = endpoint.NewEndpoint(headlessKey.DNSName, headlessKey.RecordType, targets...) } if ep != nil { ep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf("service/%s/%s", svc.Namespace, svc.Name)) endpoints = append(endpoints, ep) } } return endpoints } func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service) ([]*endpoint.Endpoint, error) { hostnames, err := fqdn.ExecTemplate(sc.fqdnTemplate, svc) if err != nil { return nil, err } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(svc.Annotations) var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, providerSpecific, setIdentifier, false)...) } return endpoints, nil } // endpointsFromService extracts the endpoints from a service object func (sc *serviceSource) endpoints(svc *v1.Service) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint // Skip endpoints if we do not want entries from annotations or service is excluded if sc.ignoreHostnameAnnotation { return endpoints } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(svc.Annotations) var hostnameList []string var internalHostnameList []string hostnameList = annotations.HostnamesFromAnnotations(svc.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, providerSpecific, setIdentifier, false)...) } internalHostnameList = annotations.InternalHostnamesFromAnnotations(svc.Annotations) for _, hostname := range internalHostnameList { endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, providerSpecific, setIdentifier, true)...) } return endpoints } // filterByServiceType filters services according to their types func (sc *serviceSource) filterByServiceType(services []*v1.Service) []*v1.Service { if !sc.serviceTypeFilter.enabled || len(services) == 0 { return services } var result []*v1.Service for _, service := range services { if sc.serviceTypeFilter.isProcessed(service.Spec.Type) { result = append(result, service) } } log.Debugf("filtered %d services out of %d with service types filter %q", len(result), len(services), slices.Collect(maps.Keys(sc.serviceTypeFilter.types))) return result } func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, providerSpecific endpoint.ProviderSpecific, setIdentifier string, useClusterIP bool) []*endpoint.Endpoint { hostname = strings.TrimSuffix(hostname, ".") resource := fmt.Sprintf("service/%s/%s", svc.Namespace, svc.Name) ttl := annotations.TTLFromAnnotations(svc.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(svc.Annotations) endpoints := make([]*endpoint.Endpoint, 0) if len(targets) == 0 { switch svc.Spec.Type { case v1.ServiceTypeLoadBalancer: if useClusterIP { targets = extractServiceIps(svc) } else { targets = extractLoadBalancerTargets(svc, sc.resolveLoadBalancerHostname) } case v1.ServiceTypeClusterIP: if svc.Spec.ClusterIP == v1.ClusterIPNone { endpoints = append(endpoints, sc.extractHeadlessEndpoints(svc, hostname, ttl)...) } else if useClusterIP || sc.publishInternal { targets = extractServiceIps(svc) } case v1.ServiceTypeNodePort: // add the nodeTargets and extract an SRV endpoint var err error targets, err = sc.extractNodePortTargets(svc) if err != nil { log.Errorf("Unable to extract targets from service %s/%s error: %v", svc.Namespace, svc.Name, err) return endpoints } endpoints = append(endpoints, sc.extractNodePortEndpoints(svc, hostname, ttl)...) case v1.ServiceTypeExternalName: targets = extractServiceExternalName(svc) } for _, en := range endpoints { en.ProviderSpecific = providerSpecific en.SetIdentifier = setIdentifier } } endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) return endpoints } func extractServiceIps(svc *v1.Service) endpoint.Targets { if svc.Spec.ClusterIP == v1.ClusterIPNone { log.Debugf("Unable to associate %s headless service with a Cluster IP", svc.Name) return endpoint.Targets{} } return endpoint.Targets{svc.Spec.ClusterIP} } func extractServiceExternalName(svc *v1.Service) endpoint.Targets { if len(svc.Spec.ExternalIPs) > 0 { return svc.Spec.ExternalIPs } return endpoint.Targets{svc.Spec.ExternalName} } func extractLoadBalancerTargets(svc *v1.Service, resolveLoadBalancerHostname bool) endpoint.Targets { if len(svc.Spec.ExternalIPs) > 0 { return svc.Spec.ExternalIPs } // Create a corresponding endpoint for each configured external entrypoint. var targets endpoint.Targets for _, lb := range svc.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) } if lb.Hostname != "" { if resolveLoadBalancerHostname { ips, err := net.LookupIP(lb.Hostname) if err != nil { log.Errorf("Unable to resolve %q: %v", lb.Hostname, err) continue } for _, ip := range ips { targets = append(targets, ip.String()) } } else { targets = append(targets, lb.Hostname) } } } return targets } func isPodStatusReady(status v1.PodStatus) bool { for _, c := range status.Conditions { if c.Type == v1.PodReady { return c.Status == v1.ConditionTrue } } return false } // nodesExternalTrafficPolicyTypeLocal returns nodes that have running pods for the given // NodePort service with externalTrafficPolicy=Local. Only nodes at the highest available // pod readiness level are returned — nodes with lower readiness are excluded when better // ones exist. func (sc *serviceSource) nodesExternalTrafficPolicyTypeLocal(svc *v1.Service) []*v1.Node { // Pod states ranked by readiness then termination; PodRunning phase is a precondition. // Values start at 1 so that the zero value of the bestPriority map acts as a // "node not yet seen" sentinel, making max() correct on the first pod without // special-casing. const ( notReady = iota + 1 // PodRunning, not ready readyTerminating // PodRunning, ready, terminating readyNonTerminating // PodRunning, ready, non-terminating ) bestPriority := map[*v1.Node]int{} maxPriority := 0 for _, v := range sc.pods(svc) { if v.Status.Phase != v1.PodRunning { continue } node, err := sc.nodeInformer.Lister().Get(v.Spec.NodeName) if err != nil { log.Debugf("Skipping pod %s/%s: node %s not found", v.Namespace, v.Name, v.Spec.NodeName) continue } p := notReady if isPodStatusReady(v.Status) { p = readyTerminating if v.GetDeletionTimestamp() == nil { p = readyNonTerminating } } bestPriority[node] = max(bestPriority[node], p) maxPriority = max(maxPriority, p) } switch maxPriority { case 0: return nil case notReady: log.Debugf("No ready pods found, falling back to running pods") case readyTerminating: log.Debugf("No non-terminating ready pods found, falling back to terminating ready pods") case readyNonTerminating: log.Debugf("Ready non-terminating pods found") } // Only return nodes at the highest readiness level available across the cluster. nodes := make([]*v1.Node, 0, len(bestPriority)) for node, p := range bestPriority { if p == maxPriority { nodes = append(nodes, node) } } return nodes } // pods retrieve a slice of pods associated with the given Service func (sc *serviceSource) pods(svc *v1.Service) []*v1.Pod { selector, err := annotations.ParseFilter(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String()) if err != nil { return nil } pods, err := sc.podInformer.Lister().Pods(svc.Namespace).List(selector) if err != nil { return nil } return pods } func (sc *serviceSource) extractNodePortTargets(svc *v1.Service) (endpoint.Targets, error) { var ( internalIPs endpoint.Targets externalIPs endpoint.Targets ipv6IPs endpoint.Targets nodes []*v1.Node ) if svc.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyTypeLocal { nodes = sc.nodesExternalTrafficPolicyTypeLocal(svc) } else { var err error nodes, err = sc.nodeInformer.Lister().List(labels.Everything()) if err != nil { return nil, err } } for _, node := range nodes { if node.Spec.Unschedulable && sc.excludeUnschedulable { log.Debugf("Skipping node %s - unschedulable", node.Name) continue } for _, address := range node.Status.Addresses { switch address.Type { case v1.NodeExternalIP: externalIPs = append(externalIPs, address.Address) case v1.NodeInternalIP: internalIPs = append(internalIPs, address.Address) if endpoint.SuitableType(address.Address) == endpoint.RecordTypeAAAA { ipv6IPs = append(ipv6IPs, address.Address) } } } } access := getAccessFromAnnotations(svc.Annotations) switch access { case "public": if sc.exposeInternalIPv6 { return append(externalIPs, ipv6IPs...), nil } return externalIPs, nil case "private": return internalIPs, nil } if len(externalIPs) > 0 { if sc.exposeInternalIPv6 { return append(externalIPs, ipv6IPs...), nil } return externalIPs, nil } return internalIPs, nil } func (sc *serviceSource) extractNodePortEndpoints(svc *v1.Service, hostname string, ttl endpoint.TTL) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint for _, port := range svc.Spec.Ports { if port.NodePort > 0 { // following the RFC 2782, SRV record must have a following format // _service._proto.name. TTL class SRV priority weight port // see https://en.wikipedia.org/wiki/SRV_record // build a target with a priority of 0, weight of 50, and pointing the given port on the given host target := fmt.Sprintf("0 50 %d %s", port.NodePort, provider.EnsureTrailingDot(hostname)) // take the service name from the K8s Service object // it is safe to use since it is DNS compatible // see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names serviceName := svc.Name // figure out the protocol protocol := strings.ToLower(string(port.Protocol)) if protocol == "" { protocol = "tcp" } recordName := fmt.Sprintf("_%s._%s.%s", serviceName, protocol, hostname) var ep *endpoint.Endpoint if ttl.IsConfigured() { ep = endpoint.NewEndpointWithTTL(recordName, endpoint.RecordTypeSRV, ttl, target) } else { ep = endpoint.NewEndpoint(recordName, endpoint.RecordTypeSRV, target) } if ep != nil { ep.WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf("service/%s/%s", svc.Namespace, svc.Name)) endpoints = append(endpoints, ep) } } } return endpoints } func (sc *serviceSource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for service") // Right now there is no way to remove event handler from informer, see: // https://github.com/kubernetes/kubernetes/issues/79610 _, _ = sc.serviceInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) if sc.listenEndpointEvents && sc.serviceTypeFilter.isRequired(v1.ServiceTypeNodePort, v1.ServiceTypeClusterIP) { _, _ = sc.endpointSlicesInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } } type serviceTypes struct { enabled bool types map[v1.ServiceType]bool } // newServiceTypesFilter processes a slice of service type filter strings and returns a serviceTypes struct. // It validates the filter against known Kubernetes service types. If the filter is empty or contains an empty string, // service type filtering is disabled. If an unknown type is found, an error is returned. func newServiceTypesFilter(filter []string) (*serviceTypes, error) { if len(filter) == 0 || slices.Contains(filter, "") { return &serviceTypes{ enabled: false, }, nil } result := make(map[v1.ServiceType]bool) for _, serviceType := range filter { if _, ok := knownServiceTypes[v1.ServiceType(serviceType)]; !ok { return nil, fmt.Errorf("unsupported service type filter: %q. Supported types are: %q", serviceType, slices.Collect(maps.Keys(knownServiceTypes))) } result[v1.ServiceType(serviceType)] = true } return &serviceTypes{ enabled: true, types: result, }, nil } func (sc *serviceTypes) isProcessed(serviceType v1.ServiceType) bool { return !sc.enabled || sc.types[serviceType] } // isRequired returns true if service type filtering is disabled or if any of the provided service types are present in the filter. // If no options are provided, it returns true. func (sc *serviceTypes) isRequired(opts ...v1.ServiceType) bool { if len(opts) == 0 || !sc.enabled { return true } for _, opt := range opts { if _, ok := sc.types[opt]; ok { return true } } return false } // conditionToBool converts an EndpointConditions condition to a bool value. func conditionToBool(v *bool) bool { if v == nil { return true // nil should be interpreted as "true" as per EndpointConditions spec } return *v } ================================================ FILE: source/service_fqdn_test.go ================================================ /* 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 source import ( "fmt" "testing" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/source/annotations" ) func TestServiceSourceFqdnTemplatingExamples(t *testing.T) { for _, tt := range []struct { title string services []*v1.Service endpointSlices []*discoveryv1.EndpointSlice fqdnTemplate string combineFQDN bool publishHostIp bool serviceTypesFilter []string expected []*endpoint.Endpoint }{ { title: "templating with multiple services", combineFQDN: true, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-1", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "170.19.58.167", }, Status: v1.ServiceStatus{}, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "kube-system", Name: "service-2", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "127.20.24.218", }, Status: v1.ServiceStatus{}, }, }, fqdnTemplate: "{{ .Name }}.{{ .Namespace }}.example.tld, all.example.org", expected: []*endpoint.Endpoint{ {DNSName: "all.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"127.20.24.218", "170.19.58.167"}}, {DNSName: "service-1.default.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"170.19.58.167"}}, {DNSName: "service-2.kube-system.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"127.20.24.218"}}, }, }, { title: "templating resolve service source with internal hostnames", combineFQDN: true, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", Annotations: map[string]string{ annotations.InternalHostnameKey: "service-one.internal.tld,service-one.internal.example.tld", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, ClusterIP: "192.240.240.3", ClusterIPs: []string{"192.240.240.3", "192.240.240.4"}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ {Hostname: "service-one.example.tld"}, }, }, }, }, }, fqdnTemplate: "{{.Name }}.example.tld", expected: []*endpoint.Endpoint{ {DNSName: "service-one.example.tld", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"service-one.example.tld"}}, {DNSName: "service-one.internal.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.240.240.3"}}, {DNSName: "service-one.internal.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.240.240.3"}}, }, }, { title: "templating resolve service by service type", services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ {Hostname: "service-one.example.tld"}, }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeExternalName, ExternalName: "bucket-name.s3.us-east-1.amazonaws.com", }, }, }, fqdnTemplate: `{{ if eq .Spec.Type "ExternalName" }}{{ .Name }}.external.example.tld{{ end}}`, expected: []*endpoint.Endpoint{ {DNSName: "service-two.external.example.tld", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bucket-name.s3.us-east-1.amazonaws.com"}}, }, }, { title: "templating resolve service with selector", combineFQDN: false, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeExternalName, ExternalName: "api.example.tld", Selector: map[string]string{ "app": "my-app", }, }, Status: v1.ServiceStatus{}, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeExternalName, ExternalName: "www.bucket-name.amazonaws.com", Selector: map[string]string{ "app": "my-website", }, }, }, }, fqdnTemplate: `{{ if eq (index .Spec.Selector "app") "my-website" }}www.{{ .Name }}.website.example.tld{{ end}}`, expected: []*endpoint.Endpoint{ {DNSName: "www.service-two.website.example.tld", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"www.bucket-name.amazonaws.com"}}, }, }, { title: "fqdn with endpoint-type annotation and loose service type filtering", serviceTypesFilter: []string{}, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "svc-ns", Name: "svc-one", Annotations: map[string]string{ annotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP, }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, ClusterIPs: []string{v1.ClusterIPNone}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{}, }, }, }, endpointSlices: []*discoveryv1.EndpointSlice{ { ObjectMeta: metav1.ObjectMeta{ Name: "svc-one-xxxxx", Namespace: "svc-ns", Labels: map[string]string{ discoveryv1.LabelServiceName: "svc-one", v1.IsHeadlessService: "", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.246"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), NodeName: testutils.ToPtr("test-node"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-1", Namespace: "svc-ns", }, }, { Addresses: []string{"100.66.2.247"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), NodeName: testutils.ToPtr("test-node"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-2", Namespace: "svc-ns", }, }, }, }, }, fqdnTemplate: "{{.Name}}.{{.Namespace}}.cluster.com", expected: []*endpoint.Endpoint{ {DNSName: "ip-10-1-164-158.internal.svc-one.svc-ns.cluster.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.0.113.10"}}, {DNSName: "svc-one.svc-ns.cluster.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.0.113.10"}}, }, }, { title: "fqdn with endpoint-type annotation and service type filtering does not include required type", serviceTypesFilter: []string{string(v1.ServiceTypeClusterIP)}, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "svc-ns", Name: "svc-one", Annotations: map[string]string{ annotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP, }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, ClusterIPs: []string{v1.ClusterIPNone}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{}, }, }, }, endpointSlices: []*discoveryv1.EndpointSlice{ { ObjectMeta: metav1.ObjectMeta{ Name: "svc-one-xxxxx", Namespace: "svc-ns", Labels: map[string]string{ discoveryv1.LabelServiceName: "svc-one", v1.IsHeadlessService: "", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.246"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), NodeName: testutils.ToPtr("test-node"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-1", Namespace: "svc-ns", }, }, { Addresses: []string{"100.66.2.247"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), NodeName: testutils.ToPtr("test-node"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-2", Namespace: "svc-ns", }, }, }, }, }, fqdnTemplate: "{{.Name}}.{{.Namespace}}.cluster.com", expected: []*endpoint.Endpoint{}, }, { title: "templating resolve service with zone PreferSameTrafficDistribution and topology.kubernetes.io/zone annotation", services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", Annotations: map[string]string{ "topology.kubernetes.io/zone": "us-west-1a", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "192.51.100.22", ExternalIPs: []string{"198.51.100.30"}, // https://kubernetes.io/docs/reference/networking/virtual-ips/#traffic-distribution TrafficDistribution: testutils.ToPtr("PreferSameZone"), }, Status: v1.ServiceStatus{}, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two", Annotations: map[string]string{ "topology.kubernetes.io/zone": "us-west-1c", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "192.51.100.5", ExternalIPs: []string{"198.51.100.32"}, TrafficDistribution: testutils.ToPtr("PreferSameZone"), }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-three", Annotations: map[string]string{ "topology.kubernetes.io/zone": "us-west-1a", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "192.51.100.33", ExternalIPs: []string{"198.51.100.70"}, TrafficDistribution: testutils.ToPtr("PreferClose"), }, }, }, // printf is used to ensure the template is evaluated as a string, as the TrafficDistribution field is a pointer. fqdnTemplate: `{{ $annotations := .ObjectMeta.Annotations }}{{ .Name }}{{ if eq (.Spec.TrafficDistribution | printf) "PreferSameZone" }}.zone.{{ index $annotations "topology.kubernetes.io/zone" }}{{ else }}.close{{ end }}.example.tld`, expected: []*endpoint.Endpoint{ {DNSName: "service-one.zone.us-west-1a.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.51.100.22"}}, {DNSName: "service-two.zone.us-west-1c.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.51.100.5"}}, {DNSName: "service-three.close.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.51.100.33"}}, }, }, { title: "templating resolve services with specific port names", services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "192.51.100.22", Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, {Name: "debug", Port: 8082}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "192.51.100.5", Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, {Name: "http2", Port: 8086}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-three", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "2041:0000:140F::875B:131B", Ports: []v1.ServicePort{ {Name: "debug", Port: 8082}, {Name: "http2", Port: 8086}, }, }, }, }, fqdnTemplate: `{{ $name := .Name }}{{ range .Spec.Ports -}}{{ $name }}{{ if eq .Name "http2" }}.http2{{ else if eq .Name "debug" }}.debug{{ end }}.example.tld.{{printf "," }}{{ end }}`, expected: []*endpoint.Endpoint{ {DNSName: "service-one.debug.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.51.100.22"}}, {DNSName: "service-one.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.51.100.22"}}, {DNSName: "service-three.debug.example.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2041:0000:140F::875B:131B"}}, {DNSName: "service-three.http2.example.tld", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2041:0000:140F::875B:131B"}}, {DNSName: "service-two.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.51.100.5"}}, {DNSName: "service-two.http2.example.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.51.100.5"}}, }, }, { title: "templating resolves headless services", publishHostIp: false, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, IPFamilies: []v1.IPFamily{v1.IPv4Protocol, v1.IPv6Protocol}, Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-three", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, Ports: []v1.ServicePort{ {Name: "debug", Port: 8082}, }, }, }, }, endpointSlices: []*discoveryv1.EndpointSlice{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one-xxxxx", Labels: map[string]string{ discoveryv1.LabelServiceName: "service-one", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.241"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-1", Namespace: "default", }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two-xxxxx", Labels: map[string]string{ discoveryv1.LabelServiceName: "service-two", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.244"}, Hostname: testutils.ToPtr("ip-10-1-164-152.internal"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-2", Namespace: "default", }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-three-xxxxx", Labels: map[string]string{ discoveryv1.LabelServiceName: "service-three", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.246"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-3", Namespace: "default", }, }, { Addresses: []string{"100.66.2.247"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-4", Namespace: "default", }, }, }, }, }, fqdnTemplate: `{{ .Name }}.org.tld`, expected: []*endpoint.Endpoint{ {DNSName: "ip-10-1-164-152.internal.service-two.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.66.2.244"}}, {DNSName: "ip-10-1-164-158.internal.service-one.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.66.2.241"}}, {DNSName: "ip-10-1-164-158.internal.service-three.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.66.2.246", "100.66.2.247"}}, {DNSName: "service-one.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.66.2.241"}}, {DNSName: "service-three.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.66.2.246", "100.66.2.247"}}, {DNSName: "service-two.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.66.2.244"}}, }, }, { title: "templating resolves headless services with publishHostIp set to true", publishHostIp: true, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, IPFamilies: []v1.IPFamily{v1.IPv4Protocol, v1.IPv6Protocol}, Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-three", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, Ports: []v1.ServicePort{ {Name: "debug", Port: 8082}, }, }, }, }, endpointSlices: []*discoveryv1.EndpointSlice{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one-xxxxx", Labels: map[string]string{ discoveryv1.LabelServiceName: "service-one", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.241"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-1", Namespace: "default", }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two-xxxxx", Labels: map[string]string{ discoveryv1.LabelServiceName: "service-two", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.244"}, Hostname: testutils.ToPtr("ip-10-1-164-152.internal"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-2", Namespace: "default", }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-three-xxxxx", Labels: map[string]string{ discoveryv1.LabelServiceName: "service-three", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.246"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-3", Namespace: "default", }, }, { Addresses: []string{"100.66.2.247"}, Hostname: testutils.ToPtr("ip-10-1-164-158.internal"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-4", Namespace: "default", }, }, }, }, }, fqdnTemplate: `{{ .Name }}.org.tld`, expected: []*endpoint.Endpoint{ {DNSName: "ip-10-1-164-152.internal.service-two.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.20.40"}}, {DNSName: "ip-10-1-164-158.internal.service-one.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.20.40"}}, {DNSName: "ip-10-1-164-158.internal.service-three.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.20.40", "10.1.20.41"}}, {DNSName: "service-one.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.20.40"}}, {DNSName: "service-three.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.20.40", "10.1.20.41"}}, {DNSName: "service-two.org.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.20.40"}}, }, }, { title: "templating resolve NodePort services with specific port names", services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeNodePort, ClusterIP: "10.96.41.131", Ports: []v1.ServicePort{ {Name: "http", Port: 80, TargetPort: intstr.FromInt32(8080), NodePort: 30080}, {Name: "debug", Port: 8082, TargetPort: intstr.FromInt32(8082), NodePort: 30082}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: "10.96.41.132", Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, {Name: "http2", Port: 8086}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-three", }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeNodePort, ClusterIP: "10.96.41.133", Ports: []v1.ServicePort{ {Name: "debug", Port: 8082, TargetPort: intstr.FromInt32(8083), Protocol: v1.ProtocolUDP, NodePort: 30083}, {Name: "minecraft", Port: 2525, TargetPort: intstr.FromInt32(25256), NodePort: 25565}, }, }, }, }, fqdnTemplate: `{{ if eq .Spec.Type "NodePort" }}{{ range .Spec.Ports }}{{ .Name }}.host.tld{{printf "," }}{{end}}{{ end }}`, expected: []*endpoint.Endpoint{ {DNSName: "_service-one._tcp.debug.host.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 30080 debug.host.tld.", "0 50 30082 debug.host.tld."}}, {DNSName: "_service-one._tcp.http.host.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 30080 http.host.tld.", "0 50 30082 http.host.tld."}}, {DNSName: "_service-three._tcp.debug.host.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 25565 debug.host.tld."}}, {DNSName: "_service-three._tcp.minecraft.host.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 25565 minecraft.host.tld."}}, {DNSName: "_service-three._udp.debug.host.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 30083 debug.host.tld."}}, {DNSName: "_service-three._udp.minecraft.host.tld", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"0 50 30083 minecraft.host.tld."}}, {DNSName: "debug.host.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.0.113.10"}}, {DNSName: "http.host.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.0.113.10"}}, {DNSName: "minecraft.host.tld", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"203.0.113.10"}}, }, }, { title: "templating resolves headless services with Kind check and label contains", fqdnTemplate: `{{ if eq .Kind "Service" }}{{ range $key, $value := .Labels }} {{ if and (contains $key "app") (contains $value "my-service-") }} {{ $.Name }}.{{ $value }}.example.com,{{ end }}{{ end }}{{ end }}`, expected: []*endpoint.Endpoint{ {DNSName: "service-one.my-service-123.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.66.2.241"}}, {DNSName: "service-two.my-service-345.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"100.66.2.244"}}, }, services: []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one", Labels: map[string]string{ "app1": "my-service-123", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two", Labels: map[string]string{ "app2": "my-service-345", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, Ports: []v1.ServicePort{ {Name: "http", Port: 8080}, }, }, }, }, endpointSlices: []*discoveryv1.EndpointSlice{ { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-one-xxxxx", Labels: map[string]string{ discoveryv1.LabelServiceName: "service-one", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.241"}, TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-1", Namespace: "default", }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "service-two-xxxxx", Labels: map[string]string{ discoveryv1.LabelServiceName: "service-two", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"100.66.2.244"}, TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod-2", Namespace: "default", }, }, }, }, }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient := fake.NewClientset() for _, el := range tt.services { _, err := kubeClient.CoreV1().Services(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{}) require.NoError(t, err) } _, err := kubeClient.CoreV1().Nodes().Create(t.Context(), &v1.Node{ ObjectMeta: metav1.ObjectMeta{Name: "test-node"}, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "203.0.113.10"}, {Type: v1.NodeInternalIP, Address: "10.0.0.10"}, }, }, }, metav1.CreateOptions{}) require.NoError(t, err) // Create endpoints and pods for the services for _, el := range tt.endpointSlices { _, err := kubeClient.DiscoveryV1().EndpointSlices(el.Namespace).Create(t.Context(), el, metav1.CreateOptions{}) require.NoError(t, err) for i, ep := range el.Endpoints { _, err = kubeClient.CoreV1().Pods(el.Namespace).Create(t.Context(), &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: ep.TargetRef.Name, Namespace: el.Namespace, }, Spec: v1.PodSpec{ Hostname: func() string { if ep.Hostname != nil { return *ep.Hostname } return "" }(), NodeName: "test-node", }, Status: v1.PodStatus{ HostIP: fmt.Sprintf("10.1.20.4%d", i), }, }, metav1.CreateOptions{}) require.NoError(t, err) } } cfg := &Config{ FQDNTemplate: tt.fqdnTemplate, CombineFQDNAndAnnotation: tt.combineFQDN, PublishHostIP: tt.publishHostIp, ServiceTypeFilter: tt.serviceTypesFilter, PublishInternal: true, AlwaysPublishNotReadyAddresses: true, ExposeInternalIPv6: true, ExcludeUnschedulable: true, LabelFilter: labels.Everything(), } src, err := NewServiceSource( t.Context(), kubeClient, cfg, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) // TODO; when all resources have the resource label, we could add this check to the validateEndpoints function. for _, ep := range endpoints { require.Contains(t, ep.Labels, endpoint.ResourceLabelKey) } }) } } ================================================ FILE: source/service_test.go ================================================ /* Copyright 2017 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 source import ( "context" "fmt" "maps" "math/rand" "net" "sort" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/informers" ) type ServiceSuite struct { suite.Suite sc Source fooWithTargets *v1.Service } func (suite *ServiceSuite) SetupTest() { fakeClient := fake.NewClientset() suite.fooWithTargets = &v1.Service{ Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, }, ObjectMeta: metav1.ObjectMeta{ Namespace: "default", Name: "foo-with-targets", Annotations: map[string]string{}, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ {IP: "8.8.8.8"}, {Hostname: "foo"}, }, }, }, } _, err := fakeClient.CoreV1().Services(suite.fooWithTargets.Namespace).Create(context.Background(), suite.fooWithTargets, metav1.CreateOptions{}) suite.NoError(err, "should successfully create service") suite.sc, err = NewServiceSource( context.TODO(), fakeClient, &Config{ FQDNTemplate: "{{.Name}}", LabelFilter: labels.Everything(), }, ) suite.NoError(err, "should initialize service source") } func (suite *ServiceSuite) TestResourceLabelIsSet() { endpoints, _ := suite.sc.Endpoints(context.Background()) for _, ep := range endpoints { suite.Equal("service/default/foo-with-targets", ep.Labels[endpoint.ResourceLabelKey], "should set correct resource label") } } func TestServiceSource(t *testing.T) { t.Parallel() suite.Run(t, new(ServiceSuite)) t.Run("Interface", testServiceSourceImplementsSource) t.Run("NewServiceSource", testServiceSourceNewServiceSource) t.Run("Endpoints", testServiceSourceEndpoints) t.Run("MultipleServices", testMultipleServicesEndpoints) } // testServiceSourceImplementsSource tests that serviceSource is a valid Source. func testServiceSourceImplementsSource(t *testing.T) { assert.Implements(t, (*Source)(nil), new(serviceSource)) } // testServiceSourceNewServiceSource tests that NewServiceSource doesn't return an error. func testServiceSourceNewServiceSource(t *testing.T) { t.Parallel() for _, tc := range []struct { title string annotationFilter string fqdnTemplate string serviceTypesFilter []string expectError bool }{ { title: "invalid template", expectError: true, fqdnTemplate: "{{.Name", }, { title: "valid empty template", expectError: false, }, { title: "valid template", expectError: false, fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", }, { title: "non-empty annotation filter label", expectError: false, annotationFilter: "kubernetes.io/ingress.class=nginx", }, { title: "non-empty service types filter", expectError: false, serviceTypesFilter: []string{string(v1.ServiceTypeClusterIP)}, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() _, err := NewServiceSource( t.Context(), fake.NewClientset(), &Config{ FQDNTemplate: tc.fqdnTemplate, AnnotationFilter: tc.annotationFilter, ServiceTypeFilter: tc.serviceTypesFilter, LabelFilter: labels.Everything(), }, ) if tc.expectError { assert.Error(t, err) } else { assert.NoError(t, err) } }) } } // testServiceSourceEndpoints tests that various services generate the correct endpoints. func testServiceSourceEndpoints(t *testing.T) { exampleDotComIP4, err := net.DefaultResolver.LookupNetIP(t.Context(), "ip4", "example.com") assert.NoError(t, err) exampleDotComIP6, err := net.DefaultResolver.LookupNetIP(t.Context(), "ip6", "example.com") assert.NoError(t, err) t.Parallel() for _, tc := range []struct { title string targetNamespace string annotationFilter string svcNamespace string svcName string svcType v1.ServiceType compatibility string fqdnTemplate string combineFQDNAndAnnotation bool ignoreHostnameAnnotation bool labels map[string]string annotations map[string]string clusterIP string externalIPs []string lbs []string serviceTypesFilter []string expected []*endpoint.Endpoint expectError bool serviceLabelSelector string resolveLoadBalancerHostname bool }{ { title: "no annotated services return no endpoints", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{}, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "no annotated services return no endpoints when ignoring annotations", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, ignoreHostnameAnnotation: true, labels: map[string]string{}, annotations: map[string]string{}, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "annotated services return an endpoint with target IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "hostname annotation on services is ignored", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, ignoreHostnameAnnotation: true, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "annotated ClusterIp aren't processed without explicit authorization", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, clusterIP: "1.2.3.4", externalIPs: []string{}, lbs: []string{}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "FQDN template with multiple hostnames return an endpoint with target IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", labels: map[string]string{}, annotations: map[string]string{}, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)}, expected: []*endpoint.Endpoint{ {DNSName: "foo.fqdn.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.fqdn.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "with excluded service type should not generate endpoints", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", labels: map[string]string{}, annotations: map[string]string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{string(v1.ServiceTypeNodePort)}, expected: []*endpoint.Endpoint{}, }, { title: "FQDN template with multiple hostnames return an endpoint with target IP when ignoring annotations", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", ignoreHostnameAnnotation: true, labels: map[string]string{}, annotations: map[string]string{}, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.fqdn.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.fqdn.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "FQDN template and annotation both with multiple hostnames return an endpoint with target IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", combineFQDNAndAnnotation: true, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org., bar.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.fqdn.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.fqdn.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "FQDN template and annotation both with multiple hostnames while ignoring annotations will only return FQDN endpoints", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", combineFQDNAndAnnotation: true, ignoreHostnameAnnotation: true, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org., bar.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.fqdn.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.fqdn.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "annotated services with multiple hostnames return an endpoint with target IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org., bar.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "annotated services with multiple hostnames and without trailing period return an endpoint with target IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org, bar.example.org", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "annotated services return an endpoint with target hostname", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"lb.example.com"}, // Kubernetes omits the trailing dot serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.example.com"}}, }, }, { title: "annotated services return an endpoint with hostname then resolve hostname", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"example.com"}, // Use a resolvable hostname for testing. serviceTypesFilter: []string{}, resolveLoadBalancerHostname: true, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: testutils.NewTargetsFromAddr(exampleDotComIP4)}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: testutils.NewTargetsFromAddr(exampleDotComIP6)}, }, }, { title: "annotated services can omit trailing dot", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org", // Trailing dot is omitted }, externalIPs: []string{}, lbs: []string{"1.2.3.4", "lb.example.com"}, // Kubernetes omits the trailing dot serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.example.com"}}, }, }, { title: "our controller type is kops dns controller", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.ControllerKey: annotations.ControllerValue, annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "different controller types are ignored even (with template specified)", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Name}}.ext-dns.test.com", labels: map[string]string{}, annotations: map[string]string{ annotations.ControllerKey: "some-other-tool", annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "services are found in target namespace", targetNamespace: "testing", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "services that are not in target namespace are ignored", targetNamespace: "testing", svcNamespace: "other-testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "services are found in all namespaces", svcNamespace: "other-testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "valid matching annotation filter expression", annotationFilter: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", "service.beta.kubernetes.io/external-traffic": "OnlyLocal", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "valid non-matching annotation filter expression", annotationFilter: "service.beta.kubernetes.io/external-traffic in (Global, OnlyLocal)", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", "service.beta.kubernetes.io/external-traffic": "SomethingElse", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "invalid annotation filter expression", annotationFilter: "service.beta.kubernetes.io/external-traffic in (Global OnlyLocal)", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", "service.beta.kubernetes.io/external-traffic": "OnlyLocal", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, expectError: true, }, { title: "valid matching annotation filter label", annotationFilter: "service.beta.kubernetes.io/external-traffic=Global", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", "service.beta.kubernetes.io/external-traffic": "Global", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "valid non-matching annotation filter label", annotationFilter: "service.beta.kubernetes.io/external-traffic=Global", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", "service.beta.kubernetes.io/external-traffic": "OnlyLocal", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "no external entrypoints return no endpoints", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "annotated service with externalIPs returns a single endpoint with multiple targets", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{"10.2.3.4", "11.2.3.4"}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.2.3.4", "11.2.3.4"}}, }, }, { title: "multiple external entrypoints return a single endpoint with multiple targets", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4", "8.8.8.8"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4", "8.8.8.8"}}, }, }, { title: "services annotated with legacy mate annotations are ignored in default mode", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ "zalando.org/dnsname": "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, }, { title: "services annotated with legacy mate annotations return an endpoint in compatibility mode", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, compatibility: "mate", labels: map[string]string{}, annotations: map[string]string{ "zalando.org/dnsname": "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "services annotated with legacy molecule annotations return an endpoint in compatibility mode", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, compatibility: "molecule", labels: map[string]string{ "dns": "route53", }, annotations: map[string]string{ "domainName": "foo.example.org., bar.example.org", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "load balancer services annotated with DNS Controller annotations return an endpoint with A and CNAME targets in compatibility mode", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, compatibility: "kops-dns-controller", labels: map[string]string{}, annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.foo.example.org", }, externalIPs: []string{}, lbs: []string{"1.2.3.4", "lb.example.com"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "internal.foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "internal.foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"lb.example.com"}}, }, }, { title: "load balancer services annotated with DNS Controller annotations return an endpoint with both annotations in compatibility mode", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, compatibility: "kops-dns-controller", labels: map[string]string{}, annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.foo.example.org., internal.bar.example.org", kopsDNSControllerHostnameAnnotationKey: "foo.example.org., bar.example.org", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "internal.foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "internal.bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "not annotated services with set fqdnTemplate return an endpoint with target IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Name}}.bar.example.com", labels: map[string]string{}, annotations: map[string]string{}, externalIPs: []string{}, lbs: []string{"1.2.3.4", "elb.com"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.bar.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.bar.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com"}}, }, }, { title: "annotated services with set fqdnTemplate annotation takes precedence", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Name}}.bar.example.com", labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4", "elb.com"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"elb.com"}}, }, }, { title: "compatibility annotated services with tmpl. compatibility takes precedence", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, compatibility: "mate", fqdnTemplate: "{{.Name}}.bar.example.com", labels: map[string]string{}, annotations: map[string]string{ "zalando.org/dnsname": "mate.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "mate.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "not annotated services with unknown tmpl field should not return anything", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, fqdnTemplate: "{{.Calibre}}.bar.example.com", labels: map[string]string{}, annotations: map[string]string{}, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{}, expectError: true, }, { title: "ttl not annotated should have RecordTTL.IsConfigured set to false", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(0)}, }, }, { title: "ttl annotated but invalid should have RecordTTL.IsConfigured set to false", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TtlKey: "foo", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(0)}, }, }, { title: "ttl annotated and is valid should set Record.TTL", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TtlKey: "10", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(10)}, }, }, { title: "ttl annotated (in duration format) and is valid should set Record.TTL", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TtlKey: "1m", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(60)}, }, }, { title: "Negative ttl is not valid", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TtlKey: "-10", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(0)}, }, }, { title: "filter on service types should include matching services", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "filter on service types should exclude non-matching services", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)}, expected: []*endpoint.Endpoint{}, }, { title: "internal-host annotated and host annotated clusterip services return an endpoint with Cluster IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.InternalHostnameKey: "foo.internal.example.org.", }, clusterIP: "1.1.1.1", externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.internal.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, }, }, { title: "internal-host annotated loadbalancer services return an endpoint with Cluster IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.InternalHostnameKey: "foo.internal.example.org.", }, clusterIP: "1.1.1.1", externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.internal.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, }, }, { title: "internal-host annotated and host annotated loadbalancer services return an endpoint with Cluster IP and an endpoint with lb IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.InternalHostnameKey: "foo.internal.example.org.", }, clusterIP: "1.1.1.1", externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, expected: []*endpoint.Endpoint{ {DNSName: "foo.internal.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "service with matching labels and fqdn filter should be included", svcNamespace: "testing", svcName: "fqdn", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{ "app": "web-external", }, clusterIP: "1.1.1.1", externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, serviceLabelSelector: "app=web-external", fqdnTemplate: "{{.Name}}.bar.example.com", expected: []*endpoint.Endpoint{ {DNSName: "fqdn.bar.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "service with matching labels and hostname annotation should be included", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{ "app": "web-external", }, clusterIP: "1.1.1.1", externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, serviceLabelSelector: "app=web-external", annotations: map[string]string{annotations.HostnameKey: "annotation.bar.example.com"}, expected: []*endpoint.Endpoint{ {DNSName: "annotation.bar.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "service without matching labels and fqdn filter should be excluded", svcNamespace: "testing", svcName: "fqdn", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{ "app": "web-internal", }, clusterIP: "1.1.1.1", externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, serviceLabelSelector: "app=web-external", fqdnTemplate: "{{.Name}}.bar.example.com", expected: []*endpoint.Endpoint{}, }, { title: "service without matching labels and hostname annotation should be excluded", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{ "app": "web-internal", }, clusterIP: "1.1.1.1", externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{}, serviceLabelSelector: "app=web-external", annotations: map[string]string{annotations.HostnameKey: "annotation.bar.example.com"}, expected: []*endpoint.Endpoint{}, }, { title: "dual-stack load-balancer service gets both addresses", svcNamespace: "testing", svcName: "foobar", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, clusterIP: "1.1.1.2,2001:db8::2", externalIPs: []string{}, lbs: []string{"1.1.1.1", "2001:db8::1"}, serviceTypesFilter: []string{}, annotations: map[string]string{annotations.HostnameKey: "foobar.example.org"}, expected: []*endpoint.Endpoint{ {DNSName: "foobar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "foobar.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, }, }, { title: "IPv6-only load-balancer service gets IPv6 endpoint", svcNamespace: "testing", svcName: "foobar-v6", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, clusterIP: "2001:db8::1", externalIPs: []string{}, lbs: []string{"2001:db8::2"}, serviceTypesFilter: []string{}, annotations: map[string]string{annotations.HostnameKey: "foobar-v6.example.org"}, expected: []*endpoint.Endpoint{ {DNSName: "foobar-v6.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::2"}}, }, }, { title: "provider-specific annotation is converted to endpoint property", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeLoadBalancer, labels: map[string]string{}, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org", annotations.AWSPrefix + "weight": "10", }, expected: []*endpoint.Endpoint{ { DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() // Create a Kubernetes testing client kubernetes := fake.NewClientset() // Create a service to test against ingresses := []v1.LoadBalancerIngress{} for _, lb := range tc.lbs { if net.ParseIP(lb) != nil { ingresses = append(ingresses, v1.LoadBalancerIngress{IP: lb}) } else { ingresses = append(ingresses, v1.LoadBalancerIngress{Hostname: lb}) } } service := &v1.Service{ Spec: v1.ServiceSpec{ Type: tc.svcType, ClusterIP: tc.clusterIP, ExternalIPs: tc.externalIPs, }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, Labels: tc.labels, Annotations: tc.annotations, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: ingresses, }, }, } _, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) sourceLabel := labels.Everything() if tc.serviceLabelSelector != "" { sourceLabel, err = labels.Parse(tc.serviceLabelSelector) require.NoError(t, err) } // Create our object under test and get the endpoints. client, err := NewServiceSource(t.Context(), kubernetes, &Config{ FQDNTemplate: tc.fqdnTemplate, AnnotationFilter: tc.annotationFilter, ServiceTypeFilter: tc.serviceTypesFilter, CombineFQDNAndAnnotation: tc.combineFQDNAndAnnotation, Compatibility: tc.compatibility, Namespace: tc.targetNamespace, ResolveLoadBalancerHostname: tc.resolveLoadBalancerHostname, IgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation, LabelFilter: sourceLabel, }, ) require.NoError(t, err) res, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, res, tc.expected) }) } } // testMultipleServicesEndpoints tests that multiple services generate correct merged endpoints func testMultipleServicesEndpoints(t *testing.T) { t.Parallel() for _, tc := range []struct { title string targetNamespace string annotationFilter string svcNamespace string svcName string svcType v1.ServiceType compatibility string fqdnTemplate string combineFQDNAndAnnotation bool ignoreHostnameAnnotation bool labels map[string]string clusterIP string services map[string]map[string]string serviceTypesFilter []string expected []*endpoint.Endpoint expectError bool }{ { "test service returns a correct end point", "", "", "testing", "foo", v1.ServiceTypeLoadBalancer, "", "", false, false, map[string]string{}, "", map[string]map[string]string{ "1.2.3.4": {annotations.HostnameKey: "foo.example.org"}, }, []string{}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/foo1.2.3.4"}}, }, false, }, { "multiple services that share same DNS should be merged into one endpoint", "", "", "testing", "foo", v1.ServiceTypeLoadBalancer, "", "", false, false, map[string]string{}, "", map[string]map[string]string{ "1.2.3.4": {annotations.HostnameKey: "foo.example.org"}, "1.2.3.5": {annotations.HostnameKey: "foo.example.org"}, "1.2.3.6": {annotations.HostnameKey: "foo.example.org"}, }, []string{}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4", "1.2.3.5", "1.2.3.6"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/foo1.2.3.4"}}, }, false, }, { "test that services with different hostnames do not get merged together", "", "", "testing", "foo", v1.ServiceTypeLoadBalancer, "", "", false, false, map[string]string{}, "", map[string]map[string]string{ "1.2.3.5": {annotations.HostnameKey: "foo.example.org"}, "10.1.1.3": {annotations.HostnameKey: "bar.example.org"}, "10.1.1.1": {annotations.HostnameKey: "bar.example.org"}, "1.2.3.4": {annotations.HostnameKey: "foo.example.org"}, "10.1.1.2": {annotations.HostnameKey: "bar.example.org"}, "20.1.1.1": {annotations.HostnameKey: "foobar.example.org"}, "1.2.3.6": {annotations.HostnameKey: "foo.example.org"}, }, []string{}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4", "1.2.3.5", "1.2.3.6"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/foo1.2.3.4"}}, {DNSName: "bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.1", "10.1.1.2", "10.1.1.3"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/foo10.1.1.1"}}, {DNSName: "foobar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"20.1.1.1"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/foo20.1.1.1"}}, }, false, }, { "test that services with different set-identifier do not get merged together", "", "", "testing", "foo", v1.ServiceTypeLoadBalancer, "", "", false, false, map[string]string{}, "", map[string]map[string]string{ "1.2.3.5": {annotations.HostnameKey: "foo.example.org", annotations.SetIdentifierKey: "a"}, "10.1.1.3": {annotations.HostnameKey: "foo.example.org", annotations.SetIdentifierKey: "b"}, }, []string{}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.5"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/foo1.2.3.5"}, SetIdentifier: "a"}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.1.1.3"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/foo10.1.1.3"}, SetIdentifier: "b"}, }, false, }, { "test that services with CNAME types do not get merged together", "", "", "testing", "foo", v1.ServiceTypeLoadBalancer, "", "", false, false, map[string]string{}, "", map[string]map[string]string{ "a.elb.com": {annotations.HostnameKey: "foo.example.org"}, "b.elb.com": {annotations.HostnameKey: "foo.example.org"}, }, []string{}, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"a.elb.com"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/fooa.elb.com"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"b.elb.com"}, Labels: map[string]string{endpoint.ResourceLabelKey: "service/testing/foob.elb.com"}}, }, false, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() // Create a Kubernetes testing client kubernetes := fake.NewClientset() // Create services to test against for lb, ants := range tc.services { ingresses := []v1.LoadBalancerIngress{} ingresses = append(ingresses, v1.LoadBalancerIngress{IP: lb}) service := &v1.Service{ Spec: v1.ServiceSpec{ Type: tc.svcType, ClusterIP: tc.clusterIP, }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName + lb, Labels: tc.labels, Annotations: ants, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: ingresses, }, }, } _, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) } // Create our object under test and get the endpoints. client, err := NewServiceSource(t.Context(), kubernetes, &Config{ FQDNTemplate: tc.fqdnTemplate, AnnotationFilter: tc.annotationFilter, ServiceTypeFilter: tc.serviceTypesFilter, CombineFQDNAndAnnotation: tc.combineFQDNAndAnnotation, Compatibility: tc.compatibility, Namespace: tc.targetNamespace, IgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) res, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, res, tc.expected) // Test that endpoint resourceLabelKey matches desired endpoint sort.SliceStable(res, func(i, j int) bool { return strings.Compare(res[i].DNSName, res[j].DNSName) < 0 }) sort.SliceStable(tc.expected, func(i, j int) bool { return strings.Compare(tc.expected[i].DNSName, tc.expected[j].DNSName) < 0 }) for i := range res { if res[i].Labels[endpoint.ResourceLabelKey] != tc.expected[i].Labels[endpoint.ResourceLabelKey] { t.Errorf("expected %s, got %s", tc.expected[i].Labels[endpoint.ResourceLabelKey], res[i].Labels[endpoint.ResourceLabelKey]) } } }) } } // testServiceSourceEndpoints tests that various services generate the correct endpoints. func TestClusterIpServices(t *testing.T) { t.Parallel() for _, tc := range []struct { title string targetNamespace string annotationFilter string svcNamespace string svcName string svcType v1.ServiceType compatibility string fqdnTemplate string ignoreHostnameAnnotation bool labels map[string]string annotations map[string]string clusterIP string expected []*endpoint.Endpoint expectError bool labelSelector string }{ { title: "hostname annotated ClusterIp services return an endpoint with Cluster IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { title: "target annotated ClusterIp services return an endpoint with the specified A", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TargetKey: "4.3.2.1", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"4.3.2.1"}}, }, }, { title: "target annotated ClusterIp services return an endpoint with the specified CNAME", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TargetKey: "bar.example.org.", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bar.example.org"}}, }, }, { title: "target annotated ClusterIp services return an endpoint with the specified AAAA", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TargetKey: "2001:DB8::1", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}}, }, }, { title: "multiple target annotated ClusterIp services return an endpoint with the specified CNAMES", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TargetKey: "bar.example.org.,baz.example.org.", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bar.example.org", "baz.example.org"}}, }, }, { title: "multiple target annotated ClusterIp services return two endpoints with the specified CNAMES and AAAA", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TargetKey: "bar.example.org.,baz.example.org.,2001:DB8::1", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bar.example.org", "baz.example.org"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}}, }, }, { title: "hostname annotated ClusterIp services are ignored", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, ignoreHostnameAnnotation: true, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{}, }, { title: "hostname and target annotated ClusterIp services are ignored", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, ignoreHostnameAnnotation: true, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TargetKey: "bar.example.org.", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{}, }, { title: "hostname and target annotated ClusterIp services return an endpoint with the specified CNAME", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TargetKey: "bar.example.org.", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bar.example.org"}}, }, }, { title: "non-annotated ClusterIp services with set fqdnTemplate return an endpoint with target IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, fqdnTemplate: "{{.Name}}.bar.example.com", clusterIP: "4.5.6.7", expected: []*endpoint.Endpoint{ {DNSName: "foo.bar.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"4.5.6.7"}}, }, }, { title: "Headless services do not generate endpoints", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, clusterIP: v1.ClusterIPNone, expected: []*endpoint.Endpoint{}, }, { title: "Headless services generate endpoints when target is specified", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.TargetKey: "bar.example.org.", }, clusterIP: v1.ClusterIPNone, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bar.example.org"}}, }, }, { title: "ClusterIP service with matching label generates an endpoint", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, fqdnTemplate: "{{.Name}}.bar.example.com", labels: map[string]string{"app": "web-internal"}, clusterIP: "4.5.6.7", expected: []*endpoint.Endpoint{ {DNSName: "foo.bar.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"4.5.6.7"}}, }, labelSelector: "app=web-internal", }, { title: "ClusterIP service with matching label and target generates a CNAME endpoint", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, fqdnTemplate: "{{.Name}}.bar.example.com", labels: map[string]string{"app": "web-internal"}, annotations: map[string]string{annotations.TargetKey: "bar.example.com."}, clusterIP: "4.5.6.7", expected: []*endpoint.Endpoint{ {DNSName: "foo.bar.example.com", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets{"bar.example.com"}}, }, labelSelector: "app=web-internal", }, { title: "ClusterIP service without matching label generates an endpoint", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, fqdnTemplate: "{{.Name}}.bar.example.com", labels: map[string]string{"app": "web-internal"}, clusterIP: "4.5.6.7", expected: []*endpoint.Endpoint{}, labelSelector: "app=web-external", }, { title: "invalid hostname does not generate endpoints", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeClusterIP, annotations: map[string]string{ annotations.HostnameKey: "this-is-an-exceedingly-long-label-that-external-dns-should-reject.example.org.", }, clusterIP: "1.2.3.4", expected: []*endpoint.Endpoint{}, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() // Create a Kubernetes testing client kubernetes := fake.NewClientset() // Create a service to test against service := &v1.Service{ Spec: v1.ServiceSpec{ Type: tc.svcType, ClusterIP: tc.clusterIP, }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, Labels: tc.labels, Annotations: tc.annotations, }, } _, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) labelSelector := labels.Everything() if tc.labelSelector != "" { labelSelector, err = labels.Parse(tc.labelSelector) require.NoError(t, err) } // Create our object under test and get the endpoints. client, _ := NewServiceSource(t.Context(), kubernetes, &Config{ FQDNTemplate: tc.fqdnTemplate, AnnotationFilter: tc.annotationFilter, Compatibility: tc.compatibility, Namespace: tc.targetNamespace, PublishInternal: true, IgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation, LabelFilter: labelSelector, }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) }) } } // testNodePortServices tests that various services generate the correct endpoints. func TestServiceSourceNodePortServices(t *testing.T) { t.Parallel() for _, tc := range []struct { title string targetNamespace string annotationFilter string svcNamespace string svcName string svcType v1.ServiceType svcTrafficPolicy v1.ServiceExternalTrafficPolicyType compatibility string fqdnTemplate string ignoreHostnameAnnotation bool exposeInternalIPv6 bool ignoreUnscheduledNodes bool labels map[string]string annotations map[string]string lbs []string expected []*endpoint.Endpoint expectError bool nodes []*v1.Node podNames []string nodeIndex []int phases []v1.PodPhase conditions []v1.PodCondition labelSelector labels.Selector deletionTimestamp []*metav1.Time }{ { title: "annotated NodePort services return an endpoint with IP addresses of the cluster's nodes", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, }, { title: "hostname annotated NodePort services are ignored", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, ignoreHostnameAnnotation: true, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, expected: []*endpoint.Endpoint{}, }, { title: "non-annotated NodePort services with set fqdnTemplate return an endpoint with target IP", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, fqdnTemplate: "{{.Name}}.bar.example.com", expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.bar.example.com", Targets: endpoint.Targets{"0 50 30192 foo.bar.example.com."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.bar.example.com", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.bar.example.com", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, }, { title: "annotated NodePort services return an endpoint with IP addresses of the private cluster's nodes", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }}, }, { title: "annotated NodePort services with ExternalTrafficPolicy=Local return an endpoint with IP addresses of the cluster's nodes where pods is running only", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, podNames: []string{"pod-0"}, nodeIndex: []int{1}, phases: []v1.PodPhase{v1.PodRunning}, conditions: []v1.PodCondition{{Type: v1.PodReady, Status: v1.ConditionFalse}}, deletionTimestamp: []*metav1.Time{{}}, }, { title: "annotated NodePort services with ExternalTrafficPolicy=Local and multiple pods on a single node return an endpoint with unique IP addresses of the cluster's nodes where pods is running only", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, podNames: []string{"pod-0", "pod-1"}, nodeIndex: []int{1, 1}, phases: []v1.PodPhase{v1.PodRunning, v1.PodRunning}, conditions: []v1.PodCondition{ {Type: v1.PodReady, Status: v1.ConditionFalse}, {Type: v1.PodReady, Status: v1.ConditionFalse}, }, deletionTimestamp: []*metav1.Time{{}, {}}, }, { title: "annotated NodePort services with ExternalTrafficPolicy=Local return pods in Ready & Running state", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, }, }, }}, podNames: []string{"pod-0", "pod-1"}, nodeIndex: []int{0, 1}, phases: []v1.PodPhase{v1.PodRunning, v1.PodRunning}, conditions: []v1.PodCondition{ {Type: v1.PodReady, Status: v1.ConditionTrue}, {Type: v1.PodReady, Status: v1.ConditionFalse}, }, deletionTimestamp: []*metav1.Time{{}, {}}, }, { title: "annotated NodePort services with ExternalTrafficPolicy=Local return pods in Ready & Running state & not in Terminating", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.1"}, RecordType: endpoint.RecordTypeA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node3", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.3"}, {Type: v1.NodeInternalIP, Address: "10.0.1.3"}, }, }, }}, podNames: []string{"pod-0", "pod-1", "pod-2"}, nodeIndex: []int{0, 1, 2}, phases: []v1.PodPhase{v1.PodRunning, v1.PodRunning, v1.PodRunning}, conditions: []v1.PodCondition{ {Type: v1.PodReady, Status: v1.ConditionTrue}, {Type: v1.PodReady, Status: v1.ConditionFalse}, {Type: v1.PodReady, Status: v1.ConditionTrue}, }, deletionTimestamp: []*metav1.Time{nil, nil, {}}, }, { title: "access=private annotation NodePort services return an endpoint with private IP addresses of the cluster's nodes", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.AccessKey: "private", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }}, }, { title: "access=public annotation NodePort services return an endpoint with external IP addresses of the cluster's nodes if exposeInternalIPv6 is unset", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.AccessKey: "public", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, }, { title: "access=public annotation NodePort services return an endpoint with public IP addresses of the cluster's nodes if exposeInternalIPv6 is set to true", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.AccessKey: "public", }, exposeInternalIPv6: true, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2", "2001:DB8::3", "2001:DB8::4"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, }, { title: "node port services annotated DNS Controller annotations return an endpoint where all targets has the node role", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, compatibility: "kops-dns-controller", labels: map[string]string{}, annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.foo.example.org., internal.bar.example.org", }, expected: []*endpoint.Endpoint{ {DNSName: "internal.foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.1.1"}}, {DNSName: "internal.foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}}, {DNSName: "internal.bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.1.1"}}, {DNSName: "internal.bar.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1"}}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", Labels: map[string]string{ "node-role.kubernetes.io/node": "", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", Labels: map[string]string{ "node-role.kubernetes.io/control-plane": "", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }}, }, { title: "node port services annotated with internal DNS Controller annotations return an endpoint in compatibility mode", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, compatibility: "kops-dns-controller", annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.foo.example.org., internal.bar.example.org", }, expected: []*endpoint.Endpoint{ {DNSName: "internal.foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}}, {DNSName: "internal.foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}}, {DNSName: "internal.bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}}, {DNSName: "internal.bar.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", Labels: map[string]string{ "node-role.kubernetes.io/node": "", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", Labels: map[string]string{ "node-role.kubernetes.io/node": "", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }}, }, { title: "node port services annotated with external DNS Controller annotations return an endpoint in compatibility mode with exposeInternalIPv6 flag set", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, compatibility: "kops-dns-controller", exposeInternalIPv6: true, annotations: map[string]string{ kopsDNSControllerHostnameAnnotationKey: "foo.example.org., bar.example.org", }, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}}, {DNSName: "bar.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}}, {DNSName: "bar.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::2"}}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", Labels: map[string]string{ "node-role.kubernetes.io/node": "", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", Labels: map[string]string{ "node-role.kubernetes.io/node": "", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }}, }, { title: "node port services annotated with both kops dns controller annotations return an empty set of addons", svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, compatibility: "kops-dns-controller", labels: map[string]string{}, annotations: map[string]string{ kopsDNSControllerInternalHostnameAnnotationKey: "internal.foo.example.org., internal.bar.example.org", kopsDNSControllerHostnameAnnotationKey: "foo.example.org., bar.example.org", }, expected: []*endpoint.Endpoint{}, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", Labels: map[string]string{ "node-role.kubernetes.io/node": "", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::1"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", Labels: map[string]string{ "node-role.kubernetes.io/node": "", }, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }}, }, { title: "NodePort services ignore unschedulable node", ignoreUnscheduledNodes: true, svcNamespace: "testing", svcName: "foo", svcType: v1.ServiceTypeNodePort, svcTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeCluster, labels: map[string]string{}, annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", annotations.AccessKey: "public", }, expected: []*endpoint.Endpoint{ {DNSName: "_foo._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org."}, RecordType: endpoint.RecordTypeSRV}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.2"}, RecordType: endpoint.RecordTypeA}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ Name: "node1", }, Spec: v1.NodeSpec{ Unschedulable: true, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "node2", }, Spec: v1.NodeSpec{ Unschedulable: false, }, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() // Create a Kubernetes testing client kubernetes := fake.NewClientset() // Create the nodes for _, node := range tc.nodes { if _, err := kubernetes.CoreV1().Nodes().Create(t.Context(), node, metav1.CreateOptions{}); err != nil { t.Fatal(err) } } // Create pods for i, podname := range tc.podNames { pod := &v1.Pod{ Spec: v1.PodSpec{ Containers: []v1.Container{}, Hostname: podname, NodeName: tc.nodes[tc.nodeIndex[i]].Name, }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: podname, Labels: tc.labels, Annotations: tc.annotations, DeletionTimestamp: tc.deletionTimestamp[i], }, Status: v1.PodStatus{ Phase: tc.phases[i], Conditions: []v1.PodCondition{tc.conditions[i]}, }, } _, err := kubernetes.CoreV1().Pods(tc.svcNamespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) } // Create a service to test against service := &v1.Service{ Spec: v1.ServiceSpec{ Type: tc.svcType, ExternalTrafficPolicy: tc.svcTrafficPolicy, Ports: []v1.ServicePort{ { NodePort: 30192, }, }, }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, Labels: tc.labels, Annotations: tc.annotations, }, } _, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) // Create our object under test and get the endpoints. client, _ := NewServiceSource(t.Context(), kubernetes, &Config{ FQDNTemplate: tc.fqdnTemplate, AnnotationFilter: tc.annotationFilter, Compatibility: tc.compatibility, Namespace: tc.targetNamespace, IgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation, ExposeInternalIPv6: tc.exposeInternalIPv6, ExcludeUnschedulable: tc.ignoreUnscheduledNodes, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) }) } } // TestHeadlessServices tests that headless services generate the correct endpoints. func TestHeadlessServices(t *testing.T) { t.Parallel() for _, tc := range []struct { title string targetNamespace string svcNamespace string svcName string svcType v1.ServiceType compatibility string fqdnTemplate string ignoreHostnameAnnotation bool exposeInternalIPv6 bool labels map[string]string svcAnnotations map[string]string podAnnotations map[string]string clusterIP string podIPs []string hostIPs []string selector map[string]string lbs []string podnames []string hostnames []string podsReady []bool publishNotReadyAddresses bool nodes []v1.Node serviceTypesFilter []string expected []*endpoint.Endpoint expectError bool }{ { "annotated Headless services return IPv4 endpoints for each selected Pod", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, []string{"", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, { "annotated Headless services return IPv6 endpoints for each selected Pod", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{}, v1.ClusterIPNone, []string{"2001:db8::1", "2001:db8::2"}, []string{"", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, false, []v1.Node{}, []string{string(v1.ServiceTypeClusterIP), string(v1.ServiceTypeLoadBalancer)}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::2"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}}, }, false, }, { "hostname annotated Headless services are ignored", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", true, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, []string{"", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{}, false, }, { "annotated Headless services return IPv4 endpoints with TTL for each selected Pod", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.TtlKey: "1", }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, []string{"", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, }, false, }, { "annotated Headless services return IPv6 endpoints with TTL for each selected Pod", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.TtlKey: "1", }, map[string]string{}, v1.ClusterIPNone, []string{"2001:db8::1", "2001:db8::2"}, []string{"", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::2"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}, RecordTTL: endpoint.TTL(1)}, }, false, }, { "annotated Headless services return endpoints for each selected Pod, which are in running state", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, []string{"", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, false}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, }, false, }, { "annotated Headless services return endpoints for all Pod if publishNotReadyAddresses is set", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, []string{"", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, false}, true, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, { "annotated Headless services return endpoints for pods missing hostname", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, []string{"", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"", ""}, []bool{true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, { "annotated Headless services return only a unique set of IPv4 targets", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.1", "1.1.1.2"}, []string{"", "", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1", "foo-3"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, { "annotated Headless services return only a unique set of IPv6 targets", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{}, v1.ClusterIPNone, []string{"2001:db8::1", "2001:db8::1", "2001:db8::2"}, []string{"", "", ""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1", "foo-3"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}}, }, false, }, { "annotated Headless services return IPv4 targets from pod annotation", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{ annotations.TargetKey: "1.2.3.4", }, v1.ClusterIPNone, []string{"1.1.1.1"}, []string{""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{}, []string{string(v1.ServiceTypeClusterIP)}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, false, }, { "annotated Headless services return IPv6 targets from pod annotation", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, map[string]string{ annotations.TargetKey: "2001:db8::4", }, v1.ClusterIPNone, []string{"2001:db8::1"}, []string{""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}}, }, false, }, { "annotated Headless services return IPv4 targets from node external IP if endpoints-type annotation is set", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP, }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1"}, []string{""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{ { Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ { Type: v1.NodeExternalIP, Address: "1.2.3.4", }, }, }, }, }, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, false, }, { "annotated Headless services return only external IPv6 targets from node IP if endpoints-type annotation is set and exposeInternalIPv6 flag is unset", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP, }, map[string]string{}, v1.ClusterIPNone, []string{"2001:db8::1"}, []string{""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{ { Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ { Type: v1.NodeInternalIP, Address: "2001:db8::4", }, { Type: v1.NodeExternalIP, Address: "2001:db8::5", }, }, }, }, }, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::5"}}, }, false, }, { "annotated Headless services return IPv6 targets from node external IP if endpoints-type annotation is set and exposeInternalIPv6 flag set", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, true, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP, }, map[string]string{}, v1.ClusterIPNone, []string{"2001:db8::1"}, []string{""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{ { Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ { Type: v1.NodeInternalIP, Address: "2001:db8::4", }, }, }, }, }, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}}, }, false, }, { "annotated Headless services return dual-stack targets from node external IP if endpoints-type annotation is set and exposeInternalIPv6 flag set", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, true, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP, }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1"}, []string{""}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{ { Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ { Type: v1.NodeExternalIP, Address: "1.2.3.4", }, { Type: v1.NodeInternalIP, Address: "2001:db8::4", }, }, }, }, }, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}}, }, false, }, { "annotated Headless services return IPv4 targets from hostIP if endpoints-type annotation is set", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.EndpointsTypeKey: EndpointsTypeHostIP, }, map[string]string{}, v1.ClusterIPNone, []string{"1.1.1.1"}, []string{"1.2.3.4"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, false, }, { "annotated Headless services return IPv6 targets from hostIP if endpoints-type annotation is set", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.EndpointsTypeKey: EndpointsTypeHostIP, }, map[string]string{}, v1.ClusterIPNone, []string{"2001:db8::1"}, []string{"2001:db8::4"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{}, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}}, }, false, }, { "headless service with endpoints-type annotation is outside of serviceTypeFilter scope", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP, }, map[string]string{}, v1.ClusterIPNone, []string{"2001:db8::1"}, []string{"2001:db8::4"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{ { Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ { Type: v1.NodeExternalIP, Address: "1.2.3.4", }, { Type: v1.NodeInternalIP, Address: "10.0.10.12", }, }, }, }, }, []string{string(v1.ServiceTypeClusterIP)}, []*endpoint.Endpoint{}, false, }, { "headless service with endpoints-type annotation is in the scope of serviceTypeFilter", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.EndpointsTypeKey: EndpointsTypeNodeExternalIP, }, map[string]string{}, v1.ClusterIPNone, []string{"2001:db8::1"}, []string{"1.2.3.4"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo"}, []string{"", "", ""}, []bool{true, true, true}, false, []v1.Node{ { Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ { Type: v1.NodeExternalIP, Address: "1.2.3.4", }, { Type: v1.NodeInternalIP, Address: "10.0.10.12", }, }, }, }, }, []string{string(v1.ServiceTypeClusterIP), string(v1.ServiceTypeNodePort)}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, false, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() // Create a Kubernetes testing client kubernetes := fake.NewClientset() service := &v1.Service{ Spec: v1.ServiceSpec{ Type: tc.svcType, ClusterIP: tc.clusterIP, Selector: tc.selector, PublishNotReadyAddresses: tc.publishNotReadyAddresses, }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, Labels: tc.labels, Annotations: tc.svcAnnotations, }, Status: v1.ServiceStatus{}, } _, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) var endpointSliceEndpoints []discoveryv1.Endpoint for i, podName := range tc.podnames { pod := &v1.Pod{ Spec: v1.PodSpec{ Containers: []v1.Container{}, Hostname: tc.hostnames[i], }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: podName, Labels: tc.labels, Annotations: tc.podAnnotations, }, Status: v1.PodStatus{ PodIP: tc.podIPs[i], HostIP: tc.hostIPs[i], }, } _, err = kubernetes.CoreV1().Pods(tc.svcNamespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) ep := discoveryv1.Endpoint{ Addresses: []string{tc.podIPs[i]}, TargetRef: &v1.ObjectReference{ APIVersion: "", Kind: "Pod", Name: podName, }, Conditions: discoveryv1.EndpointConditions{ Ready: &tc.podsReady[i], }, } endpointSliceEndpoints = append(endpointSliceEndpoints, ep) } endpointSliceLabels := maps.Clone(tc.labels) endpointSliceLabels[discoveryv1.LabelServiceName] = tc.svcName endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, Labels: endpointSliceLabels, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: endpointSliceEndpoints, } _, err = kubernetes.DiscoveryV1().EndpointSlices(tc.svcNamespace).Create(t.Context(), endpointSlice, metav1.CreateOptions{}) require.NoError(t, err) for _, node := range tc.nodes { _, err = kubernetes.CoreV1().Nodes().Create(t.Context(), &node, metav1.CreateOptions{}) require.NoError(t, err) } // Create our object under test and get the endpoints. client, _ := NewServiceSource(t.Context(), kubernetes, &Config{ FQDNTemplate: tc.fqdnTemplate, ServiceTypeFilter: tc.serviceTypesFilter, Compatibility: tc.compatibility, Namespace: tc.targetNamespace, IgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation, ExposeInternalIPv6: tc.exposeInternalIPv6, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) }) } } func TestMultipleServicesPointingToSameLoadBalancer(t *testing.T) { kubernetes := fake.NewClientset() services := []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgateway", Namespace: "default", Labels: map[string]string{ "app": "istio-ingressgateway", "istio": "ingressgateway", }, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "example.org", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, ClusterIP: "10.118.223.3", ClusterIPs: []string{"10.118.223.3"}, ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyCluster, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, IPFamilyPolicy: testutils.ToPtr(v1.IPFamilyPolicySingleStack), Ports: []v1.ServicePort{ { Name: "http2", Port: 80, Protocol: v1.ProtocolTCP, TargetPort: intstr.FromInt32(8080), NodePort: 30127, }, }, Selector: map[string]string{ "app": "istio-ingressgateway", "istio": "ingressgateway", }, SessionAffinity: v1.ServiceAffinityNone, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ { IP: "34.66.66.77", IPMode: testutils.ToPtr(v1.LoadBalancerIPModeVIP), }, }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "istio-ingressgatewayudp", Namespace: "default", Labels: map[string]string{ "app": "istio-ingressgatewayudp", "istio": "ingressgateway", }, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "example.org", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeLoadBalancer, ClusterIP: "10.118.220.130", ClusterIPs: []string{"10.118.220.130"}, ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyCluster, IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, IPFamilyPolicy: testutils.ToPtr(v1.IPFamilyPolicySingleStack), Ports: []v1.ServicePort{ { Name: "upd-dns", Port: 53, Protocol: v1.ProtocolUDP, TargetPort: intstr.FromInt32(5353), NodePort: 30873, }, }, Selector: map[string]string{ "app": "istio-ingressgatewayudp", "istio": "ingressgateway", }, SessionAffinity: v1.ServiceAffinityNone, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ { IP: "34.66.66.77", IPMode: testutils.ToPtr(v1.LoadBalancerIPModeVIP), }, }, }, }, }, } assert.NotNil(t, services) for _, svc := range services { _, err := kubernetes.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewServiceSource(t.Context(), kubernetes, &Config{ Namespace: v1.NamespaceAll, ExcludeUnschedulable: true, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) assert.NotNil(t, src) got, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, got, []*endpoint.Endpoint{ endpoint.NewEndpoint("example.org", endpoint.RecordTypeA, "34.66.66.77").WithLabel(endpoint.ResourceLabelKey, "service/default/istio-ingressgateway"), }) } func TestMultipleHeadlessServicesPointingToPodsOnTheSameNode(t *testing.T) { kubernetes := fake.NewClientset() headless := []*v1.Service{ { ObjectMeta: metav1.ObjectMeta{ Name: "kafka", Namespace: "default", Labels: map[string]string{ "app": "kafka", }, Annotations: map[string]string{ annotations.HostnameKey: "example.org", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, ClusterIPs: []string{v1.ClusterIPNone}, InternalTrafficPolicy: testutils.ToPtr(v1.ServiceInternalTrafficPolicyCluster), IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, IPFamilyPolicy: testutils.ToPtr(v1.IPFamilyPolicySingleStack), Ports: []v1.ServicePort{ { Name: "web", Port: 80, Protocol: v1.ProtocolTCP, TargetPort: intstr.FromInt32(80), }, }, Selector: map[string]string{ "app": "kafka", }, SessionAffinity: v1.ServiceAffinityNone, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{}, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "kafka-2", Namespace: "default", Labels: map[string]string{ "app": "kafka", }, Annotations: map[string]string{ annotations.HostnameKey: "example.org", }, }, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeClusterIP, ClusterIP: v1.ClusterIPNone, ClusterIPs: []string{v1.ClusterIPNone}, InternalTrafficPolicy: testutils.ToPtr(v1.ServiceInternalTrafficPolicyCluster), IPFamilies: []v1.IPFamily{v1.IPv4Protocol}, IPFamilyPolicy: testutils.ToPtr(v1.IPFamilyPolicySingleStack), Ports: []v1.ServicePort{ { Name: "web", Port: 80, Protocol: v1.ProtocolTCP, TargetPort: intstr.FromInt32(80), }, }, Selector: map[string]string{ "app": "kafka", }, SessionAffinity: v1.ServiceAffinityNone, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{}, }, }, } assert.NotNil(t, headless) pods := []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{ Name: "kafka-0", Namespace: "default", Labels: map[string]string{ "app": "kafka", appsv1.PodIndexLabel: "0", appsv1.ControllerRevisionHashLabelKey: "kafka-b8d79cdb6", appsv1.StatefulSetPodNameLabel: "kafka-0", }, OwnerReferences: []metav1.OwnerReference{ { APIVersion: "apps/v1", Kind: "StatefulSet", Name: "kafka", }, }, }, Spec: v1.PodSpec{ Hostname: "kafka-0", Subdomain: "kafka", NodeName: "local-dev-worker", Containers: []v1.Container{ { Name: "nginx", Ports: []v1.ContainerPort{ {Name: "web", ContainerPort: 80, Protocol: v1.ProtocolTCP}, }, }, }, }, Status: v1.PodStatus{ Phase: v1.PodRunning, PodIP: "10.244.1.2", PodIPs: []v1.PodIP{{IP: "10.244.1.2"}}, HostIP: "172.18.0.2", HostIPs: []v1.HostIP{{IP: "172.18.0.2"}}, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "kafka-1", Namespace: "default", Labels: map[string]string{ "app": "kafka", appsv1.PodIndexLabel: "1", appsv1.ControllerRevisionHashLabelKey: "kafka-b8d79cdb6", appsv1.StatefulSetPodNameLabel: "kafka-1", }, OwnerReferences: []metav1.OwnerReference{ { APIVersion: "apps/v1", Kind: "StatefulSet", Name: "kafka", }, }, }, Spec: v1.PodSpec{ Hostname: "kafka-1", Subdomain: "kafka", NodeName: "local-dev-worker", Containers: []v1.Container{ { Name: "nginx", Ports: []v1.ContainerPort{ {Name: "web", ContainerPort: 80, Protocol: v1.ProtocolTCP}, }, }, }, }, Status: v1.PodStatus{ Phase: v1.PodRunning, PodIP: "10.244.1.3", PodIPs: []v1.PodIP{{IP: "10.244.1.3"}}, HostIP: "172.18.0.2", HostIPs: []v1.HostIP{{IP: "172.18.0.2"}}, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "kafka-2", Namespace: "default", Labels: map[string]string{ "app": "kafka", appsv1.PodIndexLabel: "2", appsv1.ControllerRevisionHashLabelKey: "kafka-b8d79cdb6", appsv1.StatefulSetPodNameLabel: "kafka-2", }, OwnerReferences: []metav1.OwnerReference{ { APIVersion: "apps/v1", Kind: "StatefulSet", Name: "kafka", }, }, }, Spec: v1.PodSpec{ Hostname: "kafka-2", Subdomain: "kafka", NodeName: "local-dev-worker", Containers: []v1.Container{ { Name: "nginx", Ports: []v1.ContainerPort{ {Name: "web", ContainerPort: 80, Protocol: v1.ProtocolTCP}, }, }, }, }, Status: v1.PodStatus{ Phase: v1.PodRunning, PodIP: "10.244.1.4", PodIPs: []v1.PodIP{{IP: "10.244.1.4"}}, HostIP: "172.18.0.2", HostIPs: []v1.HostIP{{IP: "172.18.0.2"}}, }, }, } assert.Len(t, pods, 3) endpoints := []*discoveryv1.EndpointSlice{ { ObjectMeta: metav1.ObjectMeta{ Name: "kafka-xhrc9", Namespace: "default", Labels: map[string]string{ "app": "kafka", discoveryv1.LabelServiceName: "kafka", discoveryv1.LabelManagedBy: "endpointslice-controller.k8s.io", v1.IsHeadlessService: "", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"10.244.1.2"}, Hostname: testutils.ToPtr("kafka-0"), NodeName: testutils.ToPtr("local-dev-worker"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "kafka-0", Namespace: "default", }, Conditions: discoveryv1.EndpointConditions{ Ready: testutils.ToPtr(true), Serving: testutils.ToPtr(true), Terminating: testutils.ToPtr(false), }, }, { Addresses: []string{"10.244.1.3"}, Hostname: testutils.ToPtr("kafka-1"), NodeName: testutils.ToPtr("local-dev-worker"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "kafka-1", Namespace: "default", }, Conditions: discoveryv1.EndpointConditions{ Ready: testutils.ToPtr(true), Serving: testutils.ToPtr(true), Terminating: testutils.ToPtr(false), }, }, { Addresses: []string{"10.244.1.4"}, Hostname: testutils.ToPtr("kafka-2"), NodeName: testutils.ToPtr("local-dev-worker"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "kafka-2", Namespace: "default", }, Conditions: discoveryv1.EndpointConditions{ Ready: testutils.ToPtr(true), Serving: testutils.ToPtr(true), Terminating: testutils.ToPtr(false), }, }, }, }, { ObjectMeta: metav1.ObjectMeta{ Name: "kafka-2-svwsg", Namespace: "default", Labels: map[string]string{ "app": "kafka", discoveryv1.LabelServiceName: "kafka-2", discoveryv1.LabelManagedBy: "endpointslice-controller.k8s.io", v1.IsHeadlessService: "", }, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { Addresses: []string{"10.244.1.2"}, Hostname: testutils.ToPtr("kafka-0"), NodeName: testutils.ToPtr("local-dev-worker"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "kafka-0", Namespace: "default", }, Conditions: discoveryv1.EndpointConditions{ Ready: testutils.ToPtr(true), Serving: testutils.ToPtr(true), Terminating: testutils.ToPtr(false), }, }, { Addresses: []string{"10.244.1.3"}, Hostname: testutils.ToPtr("kafka-1"), NodeName: testutils.ToPtr("local-dev-worker"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "kafka-1", Namespace: "default", }, Conditions: discoveryv1.EndpointConditions{ Ready: testutils.ToPtr(true), Serving: testutils.ToPtr(true), Terminating: testutils.ToPtr(false), }, }, { Addresses: []string{"10.244.1.4"}, Hostname: testutils.ToPtr("kafka-2"), NodeName: testutils.ToPtr("local-dev-worker"), TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "kafka-2", Namespace: "default", }, Conditions: discoveryv1.EndpointConditions{ Ready: testutils.ToPtr(true), Serving: testutils.ToPtr(true), Terminating: testutils.ToPtr(false), }, }, }, }, } for _, svc := range headless { _, err := kubernetes.CoreV1().Services(svc.Namespace).Create(t.Context(), svc, metav1.CreateOptions{}) require.NoError(t, err) } for _, pod := range pods { _, err := kubernetes.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) } for _, ep := range endpoints { _, err := kubernetes.DiscoveryV1().EndpointSlices(ep.Namespace).Create(t.Context(), ep, metav1.CreateOptions{}) require.NoError(t, err) } src, err := NewServiceSource(t.Context(), kubernetes, &Config{ Namespace: v1.NamespaceAll, LabelFilter: labels.Everything(), ExcludeUnschedulable: true, }, ) require.NoError(t, err) assert.NotNil(t, src) got, err := src.Endpoints(t.Context()) require.NoError(t, err) want := []*endpoint.Endpoint{ // TODO: root domain records should not be created. Address them in a follow-up PR. {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.244.1.2", "10.244.1.3", "10.244.1.4"}}, {DNSName: "kafka-0.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.244.1.2"}}, {DNSName: "kafka-1.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.244.1.3"}}, {DNSName: "kafka-2.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.244.1.4"}}, } validateEndpoints(t, got, want) } // TestHeadlessServices tests that headless services generate the correct endpoints. func TestHeadlessServicesHostIP(t *testing.T) { t.Parallel() for _, tc := range []struct { title string targetNamespace string svcNamespace string svcName string svcType v1.ServiceType compatibility string fqdnTemplate string ignoreHostnameAnnotation bool labels map[string]string annotations map[string]string clusterIP string hostIPs []string selector map[string]string lbs []string podnames []string hostnames []string podsReady []bool targetRefs []*v1.ObjectReference publishNotReadyAddresses bool expected []*endpoint.Endpoint expectError bool }{ { "annotated Headless services return IPv4 endpoints for each selected Pod", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, false, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, { "annotated Headless services return IPv6 endpoints for each selected Pod", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, v1.ClusterIPNone, []string{"2001:db8::1", "2001:db8::2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, false, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::2"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}}, }, false, }, { "hostname annotated Headless services are ignored", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", true, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, false, []*endpoint.Endpoint{}, false, }, { "annotated Headless services return IPv4 endpoints with TTL for each selected Pod", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.TtlKey: "1", }, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, false, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, }, false, }, { "annotated Headless services return IPv6 endpoints with TTL for each selected Pod", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", annotations.TtlKey: "1", }, v1.ClusterIPNone, []string{"2001:db8::1", "2001:db8::2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, true}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, false, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::2"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}, RecordTTL: endpoint.TTL(1)}, }, false, }, { "annotated Headless services return endpoints for each selected Pod, which are in running state", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, false}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, false, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, }, false, }, { "annotated Headless services return endpoints for all Pod if publishNotReadyAddresses is set", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"foo-0", "foo-1"}, []bool{true, false}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, true, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, { "annotated Headless services return IPv4 endpoints for pods missing hostname", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, v1.ClusterIPNone, []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"", ""}, []bool{true, true}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, false, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, { "annotated Headless services return IPv6 endpoints for pods missing hostname", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, v1.ClusterIPNone, []string{"2001:db8::1", "2001:db8::2"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0", "foo-1"}, []string{"", ""}, []bool{true, true}, []*v1.ObjectReference{ {APIVersion: "", Kind: "Pod", Name: "foo-0"}, {APIVersion: "", Kind: "Pod", Name: "foo-1"}, }, false, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}}, }, false, }, { "annotated Headless services without a targetRef has no endpoints", "", "testing", "foo", v1.ServiceTypeClusterIP, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, v1.ClusterIPNone, []string{"1.1.1.1"}, map[string]string{ "component": "foo", }, []string{}, []string{"foo-0"}, []string{"foo-0"}, []bool{true, true}, []*v1.ObjectReference{nil}, false, []*endpoint.Endpoint{}, false, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() kubernetes := fake.NewClientset() service := &v1.Service{ Spec: v1.ServiceSpec{ Type: tc.svcType, ClusterIP: tc.clusterIP, Selector: tc.selector, PublishNotReadyAddresses: tc.publishNotReadyAddresses, }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, Labels: tc.labels, Annotations: tc.annotations, }, Status: v1.ServiceStatus{}, } _, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) var endpointsSlicesEndpoints []discoveryv1.Endpoint for i, podname := range tc.podnames { pod := &v1.Pod{ Spec: v1.PodSpec{ Containers: []v1.Container{}, Hostname: tc.hostnames[i], }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: podname, Labels: tc.labels, Annotations: tc.annotations, }, Status: v1.PodStatus{ HostIP: tc.hostIPs[i], }, } _, err = kubernetes.CoreV1().Pods(tc.svcNamespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) ep := discoveryv1.Endpoint{ Addresses: []string{"4.3.2.1"}, TargetRef: tc.targetRefs[i], Conditions: discoveryv1.EndpointConditions{ Ready: &tc.podsReady[i], }, } endpointsSlicesEndpoints = append(endpointsSlicesEndpoints, ep) } endpointSliceLabels := maps.Clone(tc.labels) endpointSliceLabels[discoveryv1.LabelServiceName] = tc.svcName endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, Labels: endpointSliceLabels, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: endpointsSlicesEndpoints, } _, err = kubernetes.DiscoveryV1().EndpointSlices(tc.svcNamespace).Create(t.Context(), endpointSlice, metav1.CreateOptions{}) require.NoError(t, err) // Create our object under test and get the endpoints. client, _ := NewServiceSource(t.Context(), kubernetes, &Config{ Namespace: tc.targetNamespace, LabelFilter: labels.Everything(), Compatibility: tc.compatibility, FQDNTemplate: tc.fqdnTemplate, IgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation, ExcludeUnschedulable: true, PublishHostIP: true, PublishInternal: true, }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) // TODO; when all resources have the resource label, we could add this check to the validateEndpoints function. for _, ep := range endpoints { require.Contains(t, ep.Labels, endpoint.ResourceLabelKey) } }) } } // TestExternalServices tests that external services generate the correct endpoints. func TestExternalServices(t *testing.T) { t.Parallel() for _, tc := range []struct { title string targetNamespace string svcNamespace string svcName string svcType v1.ServiceType compatibility string fqdnTemplate string ignoreHostnameAnnotation bool labels map[string]string annotations map[string]string externalName string externalIPs []string serviceTypeFilter []string expected []*endpoint.Endpoint expectError bool }{ { "external services return an A endpoint for the external name that is an IPv4 address", "", "testing", "foo", v1.ServiceTypeExternalName, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, "111.111.111.111", []string{}, []string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeExternalName)}, []*endpoint.Endpoint{ {DNSName: "service.example.org", Targets: endpoint.Targets{"111.111.111.111"}, RecordType: endpoint.RecordTypeA}, }, false, }, { "external services return an AAAA endpoint for the external name that is an IPv6 address", "", "testing", "foo", v1.ServiceTypeExternalName, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, "2001:db8::111", []string{}, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", Targets: endpoint.Targets{"2001:db8::111"}, RecordType: endpoint.RecordTypeAAAA}, }, false, }, { "external services return a CNAME endpoint for the external name that is a domain", "", "testing", "foo", v1.ServiceTypeExternalName, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, "remote.example.com", []string{}, []string{string(v1.ServiceTypeExternalName)}, []*endpoint.Endpoint{ {DNSName: "service.example.org", Targets: endpoint.Targets{"remote.example.com"}, RecordType: endpoint.RecordTypeCNAME}, }, false, }, { "annotated ExternalName service with externalIPs returns a single endpoint with multiple targets", "", "testing", "foo", v1.ServiceTypeExternalName, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, "service.example.org", []string{"10.2.3.4", "11.2.3.4"}, []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.2.3.4", "11.2.3.4"}}, }, false, }, { "annotated ExternalName service with externalIPs of dualstack addresses returns 2 endpoints with multiple targets", "", "testing", "foo", v1.ServiceTypeExternalName, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, "service.example.org", []string{"10.2.3.4", "11.2.3.4", "2001:db8::1", "2001:db8::2"}, []string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeExternalName)}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.2.3.4", "11.2.3.4"}}, {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}}, }, false, }, { "annotated ExternalName service with externalIPs of dualstack and excluded in serviceTypeFilter", "", "testing", "foo", v1.ServiceTypeExternalName, "", "", false, map[string]string{"component": "foo"}, map[string]string{ annotations.HostnameKey: "service.example.org", }, "service.example.org", []string{"10.2.3.4", "11.2.3.4", "2001:db8::1", "2001:db8::2"}, []string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeClusterIP)}, []*endpoint.Endpoint{}, false, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() // Create a Kubernetes testing client kubernetes := fake.NewClientset() service := &v1.Service{ Spec: v1.ServiceSpec{ Type: tc.svcType, ExternalName: tc.externalName, ExternalIPs: tc.externalIPs, }, ObjectMeta: metav1.ObjectMeta{ Namespace: tc.svcNamespace, Name: tc.svcName, Labels: tc.labels, Annotations: tc.annotations, }, Status: v1.ServiceStatus{}, } _, err := kubernetes.CoreV1().Services(service.Namespace).Create(t.Context(), service, metav1.CreateOptions{}) require.NoError(t, err) // Create our object under test and get the endpoints. client, _ := NewServiceSource(t.Context(), kubernetes, &Config{ FQDNTemplate: tc.fqdnTemplate, Compatibility: tc.compatibility, ServiceTypeFilter: tc.serviceTypeFilter, Namespace: tc.targetNamespace, IgnoreHostnameAnnotation: tc.ignoreHostnameAnnotation, ExcludeUnschedulable: true, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) if tc.expectError { require.Error(t, err) } else { require.NoError(t, err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) // TODO; when all resources have the resource label, we could add this check to the validateEndpoints function. for _, ep := range endpoints { require.Contains(t, ep.Labels, endpoint.ResourceLabelKey) } }) } } func BenchmarkServiceEndpoints(b *testing.B) { kubernetes := fake.NewClientset() service := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Namespace: "testing", Name: "foo", Annotations: map[string]string{ annotations.HostnameKey: "foo.example.org.", }, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ Ingress: []v1.LoadBalancerIngress{ {IP: "1.2.3.4"}, {IP: "8.8.8.8"}, }, }, }, } _, err := kubernetes.CoreV1().Services(service.Namespace).Create(b.Context(), service, metav1.CreateOptions{}) require.NoError(b, err) client, err := NewServiceSource(b.Context(), kubernetes, &Config{ Namespace: v1.NamespaceAll, ExcludeUnschedulable: true, LabelFilter: labels.Everything(), }, ) require.NoError(b, err) for b.Loop() { _, err := client.Endpoints(b.Context()) require.NoError(b, err) } } func TestNewServiceSourceInformersEnabled(t *testing.T) { tests := []struct { name string asserts func(svc *serviceSource) svcFilter []string }{ { name: "serviceTypeFilter is set to empty", asserts: func(svc *serviceSource) { assert.NotNil(t, svc) assert.NotNil(t, svc.serviceTypeFilter) assert.False(t, svc.serviceTypeFilter.enabled) assert.NotNil(t, svc.nodeInformer) assert.NotNil(t, svc.serviceInformer) assert.NotNil(t, svc.endpointSlicesInformer) }, }, { name: "serviceTypeFilter contains NodePort", svcFilter: []string{string(v1.ServiceTypeClusterIP)}, asserts: func(svc *serviceSource) { assert.NotNil(t, svc) assert.NotNil(t, svc.serviceTypeFilter) assert.True(t, svc.serviceTypeFilter.enabled) assert.NotNil(t, svc.serviceInformer) assert.Nil(t, svc.nodeInformer) assert.NotNil(t, svc.endpointSlicesInformer) assert.NotNil(t, svc.podInformer) }, }, { name: "serviceTypeFilter contains NodePort and ExternalName", svcFilter: []string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeExternalName)}, asserts: func(svc *serviceSource) { assert.NotNil(t, svc) assert.NotNil(t, svc.serviceTypeFilter) assert.True(t, svc.serviceTypeFilter.enabled) assert.NotNil(t, svc.serviceInformer) assert.NotNil(t, svc.nodeInformer) assert.NotNil(t, svc.endpointSlicesInformer) assert.NotNil(t, svc.podInformer) }, }, { name: "serviceTypeFilter contains ExternalName", svcFilter: []string{string(v1.ServiceTypeExternalName)}, asserts: func(svc *serviceSource) { assert.NotNil(t, svc) assert.NotNil(t, svc.serviceTypeFilter) assert.True(t, svc.serviceTypeFilter.enabled) assert.NotNil(t, svc.serviceInformer) assert.Nil(t, svc.nodeInformer) assert.Nil(t, svc.endpointSlicesInformer) assert.Nil(t, svc.podInformer) }, }, { name: "serviceTypeFilter contains LoadBalancer", svcFilter: []string{string(v1.ServiceTypeLoadBalancer)}, asserts: func(svc *serviceSource) { assert.NotNil(t, svc) assert.NotNil(t, svc.serviceTypeFilter) assert.True(t, svc.serviceTypeFilter.enabled) assert.NotNil(t, svc.serviceInformer) assert.Nil(t, svc.nodeInformer) assert.Nil(t, svc.endpointSlicesInformer) assert.Nil(t, svc.podInformer) }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { svc, err := NewServiceSource(t.Context(), fake.NewClientset(), &Config{ Namespace: "default", ServiceTypeFilter: tc.svcFilter, AlwaysPublishNotReadyAddresses: true, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) svcSrc, ok := svc.(*serviceSource) if !ok { require.Fail(t, "expected serviceSource") } tc.asserts(svcSrc) }) } } func TestNewServiceSourceWithServiceTypeFilters_Unsupported(t *testing.T) { serviceTypeFilter := []string{"ClusterIP", "ServiceTypeNotExist"} svc, err := NewServiceSource(t.Context(), fake.NewClientset(), &Config{ Namespace: "default", ServiceTypeFilter: serviceTypeFilter, LabelFilter: labels.Everything(), }, ) require.Errorf(t, err, "unsupported service type filter: \"UnknownType\". Supported types are: [\"ClusterIP\" \"NodePort\" \"LoadBalancer\" \"ExternalName\"]") require.Nil(t, svc, "ServiceSource should be nil when an unsupported service type is provided") } func TestNewServiceTypes(t *testing.T) { tests := []struct { name string filter []string wantEnabled bool wantTypes map[v1.ServiceType]bool wantErr bool }{ { name: "empty filter disables serviceTypes", filter: []string{}, wantEnabled: false, wantTypes: nil, wantErr: false, }, { name: "filter with empty string disables serviceTypes", filter: []string{""}, wantEnabled: false, wantTypes: nil, wantErr: false, }, { name: "valid filter enables serviceTypes", filter: []string{string(v1.ServiceTypeClusterIP), string(v1.ServiceTypeNodePort)}, wantEnabled: true, wantTypes: map[v1.ServiceType]bool{ v1.ServiceTypeClusterIP: true, v1.ServiceTypeNodePort: true, }, wantErr: false, }, { name: "filter with unknown type returns error", filter: []string{"UnknownType"}, wantEnabled: false, wantTypes: nil, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { st, err := newServiceTypesFilter(tt.filter) if tt.wantErr { assert.Error(t, err) assert.Nil(t, st) } else { assert.NoError(t, err) assert.Equal(t, tt.wantEnabled, st.enabled) if tt.wantTypes != nil { assert.Equal(t, tt.wantTypes, st.types) } } }) } } func TestFilterByServiceType_WithFixture(t *testing.T) { tests := []struct { name string filter *serviceTypes currentServices []*v1.Service expected int }{ { name: "all types of services with filter enabled for ServiceTypeNodePort and ServiceTypeClusterIP", currentServices: createTestServicesByType("kube-system", map[v1.ServiceType]int{ v1.ServiceTypeLoadBalancer: 3, v1.ServiceTypeNodePort: 4, v1.ServiceTypeClusterIP: 5, v1.ServiceTypeExternalName: 2, }), filter: &serviceTypes{ enabled: true, types: map[v1.ServiceType]bool{ v1.ServiceTypeNodePort: true, v1.ServiceTypeClusterIP: true, }, }, expected: 4 + 5, }, { name: "all types of services with filter enabled for ServiceTypeLoadBalancer", currentServices: createTestServicesByType("default", map[v1.ServiceType]int{ v1.ServiceTypeLoadBalancer: 3, v1.ServiceTypeNodePort: 4, v1.ServiceTypeClusterIP: 5, v1.ServiceTypeExternalName: 2, }), filter: &serviceTypes{ enabled: true, types: map[v1.ServiceType]bool{ v1.ServiceTypeLoadBalancer: true, }, }, expected: 3, }, { name: "enabled for ServiceTypeLoadBalancer when not all types are present", currentServices: createTestServicesByType("default", map[v1.ServiceType]int{ v1.ServiceTypeNodePort: 4, v1.ServiceTypeClusterIP: 5, v1.ServiceTypeExternalName: 2, }), filter: &serviceTypes{ enabled: true, types: map[v1.ServiceType]bool{ v1.ServiceTypeLoadBalancer: true, }, }, expected: 0, }, { name: "filter disabled returns all services", currentServices: createTestServicesByType("default", map[v1.ServiceType]int{ v1.ServiceTypeLoadBalancer: 3, v1.ServiceTypeNodePort: 4, v1.ServiceTypeClusterIP: 5, v1.ServiceTypeExternalName: 2, }), filter: &serviceTypes{ enabled: false, types: map[v1.ServiceType]bool{}, }, expected: 14, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sc := &serviceSource{serviceTypeFilter: tt.filter} assert.NotNil(t, sc) got := sc.filterByServiceType(tt.currentServices) assert.Len(t, got, tt.expected) }) } } func TestEndpointSlicesIndexer(t *testing.T) { ctx := t.Context() fakeClient := fake.NewClientset() // Create a dummy EndpointSlice without the service name label endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ Name: "test-slice", Namespace: "default", Labels: map[string]string{}, // No discoveryv1.LabelServiceName }, } _, err := fakeClient.DiscoveryV1().EndpointSlices("default").Create(ctx, endpointSlice, metav1.CreateOptions{}) require.NoError(t, err) // Should not error when creating the source src, err := NewServiceSource(ctx, fakeClient, &Config{ FQDNTemplate: "{{.Name}}", Namespace: "default", ExcludeUnschedulable: true, LabelFilter: labels.Everything(), }, ) require.NoError(t, err) ss, ok := src.(*serviceSource) require.True(t, ok) // Try to get EndpointSlices by index; should not panic or error, should return empty slice indexer := ss.endpointSlicesInformer.Informer().GetIndexer() slices, err := indexer.ByIndex(serviceNameIndexKey, "default/foo") require.NoError(t, err) require.Empty(t, slices) // Insert an object of the wrong type into the indexer; indexFunc should return an error and Add() should panic require.PanicsWithError(t, "unable to calculate an index entry for key \"default/not-an-endpointslice\" on index \"serviceName\": "+ "expected *v1.EndpointSlice but got *v1.Service instead", func() { _ = indexer.Add(&v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "not-an-endpointslice", Namespace: "default", }, }) }) } func TestPodTransformerInServiceSource(t *testing.T) { ctx := t.Context() fakeClient := fake.NewClientset() pod := &v1.Pod{ Spec: v1.PodSpec{ Containers: []v1.Container{{ Name: "test", }}, Hostname: "test-hostname", NodeName: "test-node", }, ObjectMeta: metav1.ObjectMeta{ Namespace: "test-ns", Name: "test-name", Labels: map[string]string{ "label1": "value1", "label2": "value2", "label3": "value3", }, Annotations: map[string]string{ "user-annotation": "value", "external-dns.alpha.kubernetes.io/hostname": "test-hostname", "external-dns.alpha.kubernetes.io/random": "value", "other/annotation": "value", }, UID: "someuid", }, Status: v1.PodStatus{ PodIP: "127.0.0.1", HostIP: "127.0.0.2", Conditions: []v1.PodCondition{{ Type: v1.PodReady, Status: v1.ConditionTrue, }, { Type: v1.ContainersReady, Status: v1.ConditionFalse, }}, }, } _, err := fakeClient.CoreV1().Pods(pod.Namespace).Create(t.Context(), pod, metav1.CreateOptions{}) require.NoError(t, err) // Should not error when creating the source src, err := NewServiceSource(ctx, fakeClient, &Config{ FQDNTemplate: "{{.Name}}", LabelFilter: labels.Everything(), }, ) require.NoError(t, err) ss, ok := src.(*serviceSource) require.True(t, ok) retrieved, err := ss.podInformer.Lister().Pods("test-ns").Get("test-name") require.NoError(t, err) // Metadata assert.Equal(t, "test-name", retrieved.Name) assert.Equal(t, "test-ns", retrieved.Namespace) assert.Empty(t, retrieved.UID) assert.Equal(t, map[string]string{ "label1": "value1", "label2": "value2", "label3": "value3", }, retrieved.Labels) // Filtered assert.Equal(t, map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "test-hostname", "external-dns.alpha.kubernetes.io/random": "value", }, retrieved.Annotations) // Spec assert.Empty(t, retrieved.Spec.Containers) assert.Equal(t, "test-hostname", retrieved.Spec.Hostname) assert.Equal(t, "test-node", retrieved.Spec.NodeName) // Status assert.Empty(t, retrieved.Status.ContainerStatuses) assert.Empty(t, retrieved.Status.InitContainerStatuses) assert.Equal(t, "127.0.0.2", retrieved.Status.HostIP) assert.Empty(t, retrieved.Status.PodIP) assert.ElementsMatch(t, []v1.PodCondition{{ Type: v1.PodReady, Status: v1.ConditionTrue, }, { Type: v1.ContainersReady, Status: v1.ConditionFalse, }}, retrieved.Status.Conditions) } // createTestServicesByType creates the requested number of services per type in the given namespace. func createTestServicesByType(ns string, typeCounts map[v1.ServiceType]int) []*v1.Service { var services []*v1.Service idx := 0 for svcType, count := range typeCounts { for range count { svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("svc-%s-%d", svcType, idx), Namespace: ns, }, Spec: v1.ServiceSpec{ Type: svcType, }, } if svcType == v1.ServiceTypeExternalName { svc.Spec.ExternalName = fmt.Sprintf("external-%d.example.com", idx) } services = append(services, svc) idx++ } } // Shuffle the resulting services to ensure randomness in the order. rand.New(rand.NewSource(time.Now().UnixNano())) rand.Shuffle(len(services), func(i, j int) { services[i], services[j] = services[j], services[i] }) return services } func TestServiceTypes_isNodeInformerRequired(t *testing.T) { tests := []struct { name string filter []string required []v1.ServiceType want bool }{ { name: "NodePort required and filter is empty", filter: []string{}, required: []v1.ServiceType{v1.ServiceTypeNodePort}, want: true, }, { name: "NodePort type present", filter: []string{string(v1.ServiceTypeNodePort)}, required: []v1.ServiceType{v1.ServiceTypeNodePort}, want: true, }, { name: "NodePort type absent, filter enabled", filter: []string{string(v1.ServiceTypeLoadBalancer)}, required: []v1.ServiceType{v1.ServiceTypeNodePort}, want: false, }, { name: "NodePort and other filters present", filter: []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)}, required: []v1.ServiceType{v1.ServiceTypeNodePort}, want: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { filter, _ := newServiceTypesFilter(tt.filter) got := filter.isRequired(tt.required...) assert.Equal(t, tt.want, got) }) } } func TestServiceSource_AddEventHandler(t *testing.T) { var fakeServiceInformer *informers.FakeServiceInformer var fakeEdpInformer *informers.FakeEndpointSliceInformer var fakeNodeInformer *informers.FakeNodeInformer tests := []struct { name string filter []string times int asserts func(t *testing.T) }{ { name: "AddEventHandler should trigger all event handlers when empty filter is provided", filter: []string{}, times: 2, asserts: func(t *testing.T) { fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1) fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 1) fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 0) }, }, { name: "AddEventHandler should trigger only service event handler", filter: []string{string(v1.ServiceTypeExternalName), string(v1.ServiceTypeLoadBalancer)}, times: 1, asserts: func(t *testing.T) { fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1) fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 0) fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 0) }, }, { name: "AddEventHandler should configure only service event handler", filter: []string{string(v1.ServiceTypeExternalName), string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeClusterIP)}, times: 2, asserts: func(t *testing.T) { fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1) fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 1) fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 0) }, }, { name: "AddEventHandler should configure all service event handlers", filter: []string{string(v1.ServiceTypeNodePort)}, times: 2, asserts: func(t *testing.T) { fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1) fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 1) fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 0) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fakeServiceInformer = new(informers.FakeServiceInformer) infSvc := testInformer{} fakeServiceInformer.On("Informer").Return(&infSvc) fakeEdpInformer = new(informers.FakeEndpointSliceInformer) infEdp := testInformer{} fakeEdpInformer.On("Informer").Return(&infEdp) fakeNodeInformer = new(informers.FakeNodeInformer) infNode := testInformer{} fakeNodeInformer.On("Informer").Return(&infNode) filter, _ := newServiceTypesFilter(tt.filter) svcSource := &serviceSource{ endpointSlicesInformer: fakeEdpInformer, serviceInformer: fakeServiceInformer, nodeInformer: fakeNodeInformer, serviceTypeFilter: filter, listenEndpointEvents: true, } svcSource.AddEventHandler(t.Context(), func() {}) assert.Equal(t, tt.times, infSvc.times+infEdp.times+infNode.times) tt.asserts(t) }) } } // Test helper functions created during extractHeadlessEndpoints refactoring func TestConvertToEndpointSlices(t *testing.T) { t.Run("converts valid EndpointSlices", func(t *testing.T) { validSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "valid-slice"}, AddressType: discoveryv1.AddressTypeIPv4, } rawObjects := []any{validSlice} result := convertToEndpointSlices(rawObjects) assert.Len(t, result, 1) assert.Equal(t, "valid-slice", result[0].Name) }) t.Run("skips invalid objects", func(t *testing.T) { invalidObject := "not-an-endpoint-slice" validSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "valid-slice"}, AddressType: discoveryv1.AddressTypeIPv4, } rawObjects := []any{invalidObject, validSlice} result := convertToEndpointSlices(rawObjects) assert.Len(t, result, 1) assert.Equal(t, "valid-slice", result[0].Name) }) t.Run("handles empty input", func(t *testing.T) { result := convertToEndpointSlices([]any{}) assert.Empty(t, result) }) t.Run("handles all invalid objects", func(t *testing.T) { rawObjects := []any{"invalid1", 123, map[string]string{"key": "value"}} result := convertToEndpointSlices(rawObjects) assert.Empty(t, result) }) } // Test for processEndpointSlice: publishPodIPs true and pod == nil func TestProcessEndpointSlices_PublishPodIPsPodNil(t *testing.T) { sc := &serviceSource{} endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "slice1", Namespace: "default"}, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { TargetRef: &v1.ObjectReference{Kind: "Pod", Name: "missing-pod"}, Conditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(true)}, }, }, } pods := []*v1.Pod{} // No pods, so pod == nil hostname := "test.example.com" endpointsType := "IPv4" publishPodIPs := true publishNotReadyAddresses := false result := sc.processHeadlessEndpointsFromSlices( pods, []*discoveryv1.EndpointSlice{endpointSlice}, hostname, endpointsType, publishPodIPs, publishNotReadyAddresses) assert.Empty(t, result, "No targets should be added when pod is nil and publishPodIPs is true") } // Test for processEndpointSlice: publishPodIPs true and unsupported address type triggers log.Debugf skip func TestProcessEndpointSlices_PublishPodIPsUnsupportedAddressType(t *testing.T) { sc := &serviceSource{} endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "slice2", Namespace: "default"}, AddressType: discoveryv1.AddressTypeFQDN, // unsupported type Endpoints: []discoveryv1.Endpoint{ { TargetRef: &v1.ObjectReference{Kind: "Pod", Name: "some-pod"}, Conditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(true)}, }, }, } pods := []*v1.Pod{{ObjectMeta: metav1.ObjectMeta{Name: "some-pod"}}} hostname := "test.example.com" endpointsType := "FQDN" publishPodIPs := true publishNotReadyAddresses := false result := sc.processHeadlessEndpointsFromSlices( pods, []*discoveryv1.EndpointSlice{endpointSlice}, hostname, endpointsType, publishPodIPs, publishNotReadyAddresses) assert.Empty(t, result, "No targets should be added for unsupported address type when publishPodIPs is true") } // Test for missing coverage: publishPodIPs false scenario func TestProcessEndpointSlices_PublishPodIPsFalse(t *testing.T) { sc := &serviceSource{} endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "slice1", Namespace: "default"}, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { TargetRef: &v1.ObjectReference{Kind: "Pod", Name: "test-pod"}, Conditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(true)}, Addresses: []string{"10.0.0.1"}, }, }, } pods := []*v1.Pod{{ ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, Status: v1.PodStatus{PodIP: "10.0.0.1"}, }} hostname := "test.example.com" endpointsType := "IPv4" publishPodIPs := false // This should allow processing publishNotReadyAddresses := false result := sc.processHeadlessEndpointsFromSlices( pods, []*discoveryv1.EndpointSlice{endpointSlice}, hostname, endpointsType, publishPodIPs, publishNotReadyAddresses) assert.NotEmpty(t, result, "Targets should be added when publishPodIPs is false") } // Test for missing coverage: not ready endpoints with publishNotReadyAddresses true func TestProcessEndpointSlices_NotReadyWithPublishNotReady(t *testing.T) { sc := &serviceSource{} endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "slice1", Namespace: "default"}, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { TargetRef: &v1.ObjectReference{Kind: "Pod", Name: "test-pod"}, Conditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(false)}, // Not ready Addresses: []string{"10.0.0.1"}, }, }, } pods := []*v1.Pod{{ ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, Status: v1.PodStatus{PodIP: "10.0.0.1"}, }} hostname := "test.example.com" endpointsType := "IPv4" publishPodIPs := false publishNotReadyAddresses := true // This should allow not-ready endpoints result := sc.processHeadlessEndpointsFromSlices( pods, []*discoveryv1.EndpointSlice{endpointSlice}, hostname, endpointsType, publishPodIPs, publishNotReadyAddresses) assert.NotEmpty(t, result, "Not ready endpoints should be processed when publishNotReadyAddresses is true") } // Test getTargetsForDomain with empty ep.Addresses func TestGetTargetsForDomain_EmptyAddresses(t *testing.T) { sc := &serviceSource{} pod := &v1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, Status: v1.PodStatus{PodIP: "10.0.0.1"}, } ep := discoveryv1.Endpoint{ Addresses: []string{}, // Empty addresses } endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "test-slice", Namespace: "default"}, AddressType: discoveryv1.AddressTypeIPv4, } endpointsType := "IPv4" headlessDomain := "test.example.com" targets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain) assert.Empty(t, targets, "Should return empty targets when ep.Addresses is empty") } // Test getTargetsForDomain with EndpointsTypeHostIP func TestGetTargetsForDomain_HostIP(t *testing.T) { sc := &serviceSource{publishHostIP: false} pod := &v1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, Status: v1.PodStatus{HostIP: "192.168.1.100", PodIP: "10.0.0.1"}, } ep := discoveryv1.Endpoint{ Addresses: []string{"10.0.0.1"}, } endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "test-slice", Namespace: "default"}, AddressType: discoveryv1.AddressTypeIPv4, } endpointsType := EndpointsTypeHostIP headlessDomain := "test.example.com" targets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain) assert.Contains(t, targets, "192.168.1.100", "Should return HostIP when endpointsType is HostIP") } // Test getTargetsForDomain with NodeExternalIP and nodeInformer func TestGetTargetsForDomain_NodeExternalIP(t *testing.T) { // Create a fake node informer with a node node := &v1.Node{ ObjectMeta: metav1.ObjectMeta{Name: "test-node"}, Status: v1.NodeStatus{ Addresses: []v1.NodeAddress{ {Type: v1.NodeExternalIP, Address: "203.0.113.10"}, {Type: v1.NodeInternalIP, Address: "10.0.0.10"}, }, }, } client := fake.NewClientset(node) kubeInformers := kubeinformers.NewSharedInformerFactory(client, 0) nodeInformer := kubeInformers.Core().V1().Nodes() // Add the node to the informer nodeInformer.Informer().GetStore().Add(node) sc := &serviceSource{ nodeInformer: nodeInformer, } pod := &v1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, Spec: v1.PodSpec{NodeName: "test-node"}, Status: v1.PodStatus{PodIP: "10.0.0.1"}, } ep := discoveryv1.Endpoint{ Addresses: []string{"10.0.0.1"}, } endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "test-slice", Namespace: "default"}, AddressType: discoveryv1.AddressTypeIPv4, } endpointsType := EndpointsTypeNodeExternalIP headlessDomain := "test.example.com" targets := sc.getTargetsForDomain(pod, ep, endpointSlice, endpointsType, headlessDomain) assert.Contains(t, targets, "203.0.113.10", "Should return NodeExternalIP") } func TestFindPodForEndpoint(t *testing.T) { pods := []*v1.Pod{ { ObjectMeta: metav1.ObjectMeta{Name: "pod1"}, }, { ObjectMeta: metav1.ObjectMeta{Name: "pod2"}, }, } t.Run("finds matching pod", func(t *testing.T) { endpoint := discoveryv1.Endpoint{ TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod1", }, } result := findPodForEndpoint(endpoint, pods) assert.NotNil(t, result) assert.Equal(t, "pod1", result.Name) }) t.Run("returns nil for nil TargetRef", func(t *testing.T) { endpoint := discoveryv1.Endpoint{ TargetRef: nil, } result := findPodForEndpoint(endpoint, pods) assert.Nil(t, result) }) t.Run("returns nil for non-Pod kind", func(t *testing.T) { endpoint := discoveryv1.Endpoint{ TargetRef: &v1.ObjectReference{ Kind: "Service", Name: "pod1", }, } result := findPodForEndpoint(endpoint, pods) assert.Nil(t, result) }) t.Run("returns nil for non-empty APIVersion", func(t *testing.T) { endpoint := discoveryv1.Endpoint{ TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "pod1", APIVersion: "v1", }, } result := findPodForEndpoint(endpoint, pods) assert.Nil(t, result) }) t.Run("returns nil for non-existent pod", func(t *testing.T) { endpoint := discoveryv1.Endpoint{ TargetRef: &v1.ObjectReference{ Kind: "Pod", Name: "non-existent-pod", }, } result := findPodForEndpoint(endpoint, pods) assert.Nil(t, result) }) } func TestBuildHeadlessEndpoints(t *testing.T) { svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "test-service", Namespace: "default", }, } t.Run("builds endpoints from targets", func(t *testing.T) { targetsByHeadlessDomainAndType := map[endpoint.EndpointKey]endpoint.Targets{ {DNSName: "test.example.com", RecordType: endpoint.RecordTypeA}: {"1.2.3.4", "5.6.7.8"}, {DNSName: "test.example.com", RecordType: endpoint.RecordTypeAAAA}: {"2001:db8::1"}, } result := buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, endpoint.TTL(0)) assert.Len(t, result, 2) // Check A record aRecord := findEndpointByType(result, endpoint.RecordTypeA) assert.NotNil(t, aRecord) assert.Equal(t, "test.example.com", aRecord.DNSName) assert.Contains(t, aRecord.Targets, "1.2.3.4") assert.Contains(t, aRecord.Targets, "5.6.7.8") assert.Equal(t, "service/default/test-service", aRecord.Labels[endpoint.ResourceLabelKey]) // Check AAAA record aaaaRecord := findEndpointByType(result, endpoint.RecordTypeAAAA) assert.NotNil(t, aaaaRecord) assert.Equal(t, "test.example.com", aaaaRecord.DNSName) assert.Contains(t, aaaaRecord.Targets, "2001:db8::1") }) t.Run("deduplicates targets", func(t *testing.T) { targetsByHeadlessDomainAndType := map[endpoint.EndpointKey]endpoint.Targets{ {DNSName: "test.example.com", RecordType: endpoint.RecordTypeA}: {"1.2.3.4", "1.2.3.4", "5.6.7.8"}, } result := buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, endpoint.TTL(0)) assert.Len(t, result, 1) assert.Len(t, result[0].Targets, 2) assert.Contains(t, result[0].Targets, "1.2.3.4") assert.Contains(t, result[0].Targets, "5.6.7.8") }) t.Run("handles TTL configuration", func(t *testing.T) { targetsByHeadlessDomainAndType := map[endpoint.EndpointKey]endpoint.Targets{ {DNSName: "test.example.com", RecordType: endpoint.RecordTypeA}: {"1.2.3.4"}, } result := buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, endpoint.TTL(300)) assert.Len(t, result, 1) assert.Equal(t, endpoint.TTL(300), result[0].RecordTTL) }) t.Run("sorts endpoints deterministically", func(t *testing.T) { targetsByHeadlessDomainAndType := map[endpoint.EndpointKey]endpoint.Targets{ {DNSName: "z.example.com", RecordType: endpoint.RecordTypeA}: {"1.2.3.4"}, {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA}: {"5.6.7.8"}, {DNSName: "a.example.com", RecordType: endpoint.RecordTypeAAAA}: {"2001:db8::1"}, } result := buildHeadlessEndpoints(svc, targetsByHeadlessDomainAndType, endpoint.TTL(0)) assert.Len(t, result, 3) // Should be sorted by DNSName first, then by RecordType assert.Equal(t, "a.example.com", result[0].DNSName) assert.Equal(t, endpoint.RecordTypeA, result[0].RecordType) assert.Equal(t, "a.example.com", result[1].DNSName) assert.Equal(t, endpoint.RecordTypeAAAA, result[1].RecordType) assert.Equal(t, "z.example.com", result[2].DNSName) }) t.Run("handles empty targets", func(t *testing.T) { result := buildHeadlessEndpoints(svc, map[endpoint.EndpointKey]endpoint.Targets{}, endpoint.TTL(0)) assert.Empty(t, result) }) } // Test for missing coverage: pod with hostname creates additional headless domains func TestProcessEndpointSlices_PodWithHostname(t *testing.T) { sc := &serviceSource{} endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{Name: "slice1", Namespace: "default"}, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: []discoveryv1.Endpoint{ { TargetRef: &v1.ObjectReference{Kind: "Pod", Name: "test-pod"}, Conditions: discoveryv1.EndpointConditions{Ready: testutils.ToPtr(true)}, Addresses: []string{"10.0.0.1"}, }, }, } pods := []*v1.Pod{{ ObjectMeta: metav1.ObjectMeta{Name: "test-pod"}, Spec: v1.PodSpec{Hostname: "my-pod"}, // Non-empty hostname Status: v1.PodStatus{PodIP: "10.0.0.1"}, }} hostname := "test.example.com" endpointsType := "IPv4" publishPodIPs := false publishNotReadyAddresses := false result := sc.processHeadlessEndpointsFromSlices( pods, []*discoveryv1.EndpointSlice{endpointSlice}, hostname, endpointsType, publishPodIPs, publishNotReadyAddresses) assert.NotEmpty(t, result, "Should create targets for pod with hostname") // Check that both the base hostname and pod-specific hostname are created var foundBaseHostname, foundPodHostname bool for key := range result { if key.DNSName == "test.example.com" { foundBaseHostname = true } if key.DNSName == "my-pod.test.example.com" { foundPodHostname = true } } assert.True(t, foundBaseHostname, "Should create endpoint for base hostname") assert.True(t, foundPodHostname, "Should create endpoint for pod-specific hostname when pod.Spec.Hostname is set") } func TestProcessEndpoint_Service_RefObjectExist(t *testing.T) { elements := []runtime.Object{ &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Namespace: "01", Name: "foo", Annotations: map[string]string{ annotations.HostnameKey: "foo.example.com", annotations.TargetKey: "1.2.3", }, UID: "uid-1", }, }, &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Namespace: "02", Name: "bar", Annotations: map[string]string{ annotations.HostnameKey: "bar.example.com", annotations.TargetKey: "3.4.5", }, UID: "uid-2", }, }, } fakeClient := fake.NewClientset(elements...) client, err := NewServiceSource( t.Context(), fakeClient, &Config{ LabelFilter: labels.Everything(), }, ) require.NoError(t, err) endpoints, err := client.Endpoints(t.Context()) require.NoError(t, err) testutils.AssertEndpointsHaveRefObject(t, endpoints, types.Service, len(elements)) } func TestNodesExternalTrafficPolicyTypeLocal(t *testing.T) { now := metav1.Now() makeNode := func(name, ip string) *v1.Node { return &v1.Node{ ObjectMeta: metav1.ObjectMeta{Name: name}, Status: v1.NodeStatus{Addresses: []v1.NodeAddress{{Type: v1.NodeExternalIP, Address: ip}}}, } } makePod := func(name, nodeName string, ready bool, deletionTimestamp *metav1.Time) *v1.Pod { readiness := v1.ConditionFalse if ready { readiness = v1.ConditionTrue } return &v1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "testing", DeletionTimestamp: deletionTimestamp}, Spec: v1.PodSpec{NodeName: nodeName}, Status: v1.PodStatus{ Phase: v1.PodRunning, Conditions: []v1.PodCondition{{Type: v1.PodReady, Status: readiness}}, }, } } makeResourceSource := func(t *testing.T, nodes []*v1.Node, pods []*v1.Pod) *serviceSource { t.Helper() client := fake.NewClientset() inf := kubeinformers.NewSharedInformerFactory(client, 0) nodeInformer := inf.Core().V1().Nodes() podInformer := inf.Core().V1().Pods() for _, n := range nodes { require.NoError(t, nodeInformer.Informer().GetStore().Add(n)) } for _, p := range pods { require.NoError(t, podInformer.Informer().GetStore().Add(p)) } return &serviceSource{podInformer: podInformer, nodeInformer: nodeInformer} } svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "testing"}, Spec: v1.ServiceSpec{ Type: v1.ServiceTypeNodePort, ExternalTrafficPolicy: v1.ServiceExternalTrafficPolicyTypeLocal, }, } // nodeNames extracts node names from a result slice for easy assertion. nodeNames := func(nodes []*v1.Node) []string { names := make([]string, len(nodes)) for i, n := range nodes { names[i] = n.Name } sort.Strings(names) return names } t.Run("best pod wins during rolling update regardless of iteration order", func(t *testing.T) { // node1: not-ready replacement pod + ready existing pod (rolling update) // node2: single ready pod // The informer cache is a Go map so pod iteration order is randomised // each call. Over 100 iterations pod-0 will be processed before pod-1 // roughly half the time, reliably triggering the bug if present. sc := makeResourceSource(t, []*v1.Node{makeNode("node1", "54.10.11.1"), makeNode("node2", "54.10.11.2")}, []*v1.Pod{ makePod("pod-0", "node1", false, nil), // not-ready replacement makePod("pod-1", "node1", true, nil), // ready existing makePod("pod-2", "node2", true, nil), // ready }, ) for i := range 100 { got := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc)) require.Containsf(t, got, "node1", "iteration %d: node1 dropped despite having a ready pod", i) require.Containsf(t, got, "node2", "iteration %d: node2 dropped despite having a ready pod", i) } }) t.Run("best pod state wins per node deterministically", func(t *testing.T) { // Explicitly verify that each priority upgrade path lands in the correct tier, // independent of iteration order. // node1: notReady pod + readyNonTerminating pod → must appear in nodes (top tier) // node2: notReady pod + readyTerminating pod → must appear in nodesReady (mid tier) // node3: notReady pod only → must appear in nodesRunning (low tier) // Because node1 is in the top tier the fallback switch returns only node1. sc := makeResourceSource(t, []*v1.Node{ makeNode("node1", "54.10.11.1"), makeNode("node2", "54.10.11.2"), makeNode("node3", "54.10.11.3"), }, []*v1.Pod{ makePod("pod-0", "node1", false, nil), // notReady makePod("pod-1", "node1", true, nil), // readyNonTerminating — upgrades node1 makePod("pod-2", "node2", false, nil), // notReady makePod("pod-3", "node2", true, &now), // readyTerminating — upgrades node2 makePod("pod-4", "node3", false, nil), // notReady only }, ) got := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc)) assert.Equal(t, []string{"node1"}, got, "fallback should select the top-tier node only") }) t.Run("falls back to nodesReady when all ready pods are terminating", func(t *testing.T) { sc := makeResourceSource(t, []*v1.Node{makeNode("node1", "54.10.11.1")}, []*v1.Pod{makePod("pod-0", "node1", true, &now)}, // ready + terminating ) got := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc)) assert.Equal(t, []string{"node1"}, got) }) t.Run("falls back to nodesRunning when no pod is ready", func(t *testing.T) { sc := makeResourceSource(t, []*v1.Node{makeNode("node1", "54.10.11.1")}, []*v1.Pod{makePod("pod-0", "node1", false, nil)}, // running, not ready ) got := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc)) assert.Equal(t, []string{"node1"}, got) }) t.Run("skips pods that are not in Running phase", func(t *testing.T) { pendingPod := &v1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "pod-pending", Namespace: "testing"}, Spec: v1.PodSpec{NodeName: "node1"}, Status: v1.PodStatus{Phase: v1.PodPending}, } sc := makeResourceSource(t, []*v1.Node{makeNode("node1", "54.10.11.1")}, []*v1.Pod{pendingPod}, ) got := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc)) assert.Empty(t, got) }) t.Run("skips pods whose node is not found in the informer", func(t *testing.T) { sc := makeResourceSource(t, []*v1.Node{}, // node1 not registered []*v1.Pod{makePod("pod-0", "node1", true, nil)}, ) got := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc)) assert.Empty(t, got) }) t.Run("returns nil when pod list is empty", func(t *testing.T) { sc := makeResourceSource(t, []*v1.Node{makeNode("node1", "54.10.11.1")}, []*v1.Pod{}, ) got := sc.nodesExternalTrafficPolicyTypeLocal(svc) assert.Nil(t, got) }) t.Run("returns only non-terminating ready nodes when mixed with terminating ready nodes", func(t *testing.T) { sc := makeResourceSource(t, []*v1.Node{makeNode("node1", "54.10.11.1"), makeNode("node2", "54.10.11.2")}, []*v1.Pod{ makePod("pod-0", "node1", true, nil), // ready, non-terminating makePod("pod-1", "node2", true, &now), // ready, terminating }, ) got := nodeNames(sc.nodesExternalTrafficPolicyTypeLocal(svc)) assert.Equal(t, []string{"node1"}, got) }) } // Helper function to find endpoint by record type func findEndpointByType(endpoints []*endpoint.Endpoint, recordType string) *endpoint.Endpoint { for _, ep := range endpoints { if ep.RecordType == recordType { return ep } } return nil } ================================================ FILE: source/shared_test.go ================================================ /* Copyright 2017 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 source import ( "reflect" "sort" "testing" "sigs.k8s.io/external-dns/endpoint" ) func sortEndpoints(endpoints []*endpoint.Endpoint) { for _, ep := range endpoints { sort.Strings([]string(ep.Targets)) } sort.Slice(endpoints, func(i, k int) bool { // Sort by DNSName, RecordType, and Targets ei, ek := endpoints[i], endpoints[k] if ei.DNSName != ek.DNSName { return ei.DNSName < ek.DNSName } if ei.RecordType != ek.RecordType { return ei.RecordType < ek.RecordType } // Targets are sorted ahead of time. for j, ti := range ei.Targets { if j >= len(ek.Targets) { return true } if tk := ek.Targets[j]; ti != tk { return ti < tk } } return false }) } func validateEndpoints(t *testing.T, endpoints, expected []*endpoint.Endpoint) { t.Helper() if len(endpoints) != len(expected) { t.Fatalf("expected %d endpoints, got %d", len(expected), len(endpoints)) } // Make sure endpoints are sorted - validateEndpoint() depends on it. sortEndpoints(endpoints) sortEndpoints(expected) for i := range endpoints { validateEndpoint(t, endpoints[i], expected[i]) } } func validateEndpoint(t *testing.T, endpoint, expected *endpoint.Endpoint) { t.Helper() if endpoint.DNSName != expected.DNSName { t.Errorf("DNSName expected %q, got %q", expected.DNSName, endpoint.DNSName) } if !endpoint.Targets.Same(expected.Targets) { t.Errorf("Targets expected %q, got %q", expected.Targets, endpoint.Targets) } if endpoint.RecordTTL != expected.RecordTTL { t.Errorf("RecordTTL expected %v, got %v", expected.RecordTTL, endpoint.RecordTTL) } // if a non-empty record type is expected, check that it matches. if endpoint.RecordType != expected.RecordType { t.Errorf("RecordType expected %q, got %q", expected.RecordType, endpoint.RecordType) } // if non-empty labels are expected, check that they match. if expected.Labels != nil && !reflect.DeepEqual(endpoint.Labels, expected.Labels) { t.Errorf("Labels expected %s, got %s", expected.Labels, endpoint.Labels) } if (len(expected.ProviderSpecific) != 0 || len(endpoint.ProviderSpecific) != 0) && !reflect.DeepEqual(endpoint.ProviderSpecific, expected.ProviderSpecific) { t.Errorf("ProviderSpecific expected %s, got %s", expected.ProviderSpecific, endpoint.ProviderSpecific) } if endpoint.SetIdentifier != expected.SetIdentifier { t.Errorf("SetIdentifier expected %q, got %q", expected.SetIdentifier, endpoint.SetIdentifier) } } ================================================ FILE: source/skipper_routegroup.go ================================================ /* Copyright 2017 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 source import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "net" "net/http" "net/url" "os" "strings" "sync" "text/template" "time" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" ) const ( defaultIdleConnTimeout = 30 * time.Second // DefaultRoutegroupVersion is the default version for route groups. DefaultRoutegroupVersion = "zalando.org/v1" routeGroupListResource = "/apis/%s/routegroups" routeGroupNamespacedResource = "/apis/%s/namespaces/%s/routegroups" ) // +externaldns:source:name=skipper-routegroup // +externaldns:source:category=Ingress Controllers // +externaldns:source:description=Creates DNS entries from Skipper RouteGroup resources // +externaldns:source:resources=RouteGroup.zalando.org // +externaldns:source:filters=annotation // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=true type routeGroupSource struct { cli routeGroupListClient apiServer string namespace string apiEndpoint string annotationFilter string fqdnTemplate *template.Template combineFQDNAnnotation bool ignoreHostnameAnnotation bool } // for testing type routeGroupListClient interface { getRouteGroupList(string) (*routeGroupList, error) } type routeGroupClient struct { mu sync.Mutex quit chan struct{} client *http.Client token string tokenFile string } func newRouteGroupClient(token, tokenPath string, timeout time.Duration) *routeGroupClient { const ( tokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" rootCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" ) if tokenPath != "" { tokenPath = tokenFile } tr := &http.Transport{ DialContext: (&net.Dialer{ Timeout: timeout, KeepAlive: 30 * time.Second, DualStack: true, }).DialContext, TLSHandshakeTimeout: 3 * time.Second, ResponseHeaderTimeout: timeout, IdleConnTimeout: defaultIdleConnTimeout, MaxIdleConns: 5, MaxIdleConnsPerHost: 5, } cli := &routeGroupClient{ client: &http.Client{ Transport: tr, }, quit: make(chan struct{}), tokenFile: tokenPath, token: token, } go func() { for { select { case <-time.After(tr.IdleConnTimeout): tr.CloseIdleConnections() cli.updateToken() case <-cli.quit: return } } }() // in cluster config, errors are treated as not running in cluster cli.updateToken() // cluster internal use custom CA to reach TLS endpoint rootCA, err := os.ReadFile(rootCAFile) if err != nil { return cli } certPool := x509.NewCertPool() if !certPool.AppendCertsFromPEM(rootCA) { return cli } tr.TLSClientConfig = &tls.Config{ MinVersion: tls.VersionTLS12, RootCAs: certPool, } return cli } func (cli *routeGroupClient) updateToken() { if cli.tokenFile == "" { return } token, err := os.ReadFile(cli.tokenFile) if err != nil { log.Errorf("Failed to read token from file (%s): %v", cli.tokenFile, err) return } cli.mu.Lock() cli.token = string(token) cli.mu.Unlock() } func (cli *routeGroupClient) getToken() string { cli.mu.Lock() defer cli.mu.Unlock() return cli.token } func (cli *routeGroupClient) getRouteGroupList(url string) (*routeGroupList, error) { resp, err := cli.get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to get routegroup list from %s, got: %s", url, resp.Status) } var rgs routeGroupList err = json.NewDecoder(resp.Body).Decode(&rgs) if err != nil { return nil, err } return &rgs, nil } func (cli *routeGroupClient) get(url string) (*http.Response, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } return cli.do(req) } func (cli *routeGroupClient) do(req *http.Request) (*http.Response, error) { if tok := cli.getToken(); tok != "" && req.Header.Get("Authorization") == "" { req.Header.Set("Authorization", "Bearer "+tok) } return cli.client.Do(req) } // NewRouteGroupSource creates a new routeGroupSource with the given config. func NewRouteGroupSource(cfg *Config, token, tokenPath, apiServerURL string) (Source, error) { tmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } routeGroupVersion := cfg.SkipperRouteGroupVersion if routeGroupVersion == "" { routeGroupVersion = DefaultRoutegroupVersion } cli := newRouteGroupClient(token, tokenPath, cfg.RequestTimeout) u, err := url.Parse(apiServerURL) if err != nil { return nil, err } apiServer := u.String() // strip port if well known port, because of TLS certificate match if u.Scheme == "https" && u.Port() == "443" { // correctly handle IPv6 addresses by keeping surrounding `[]`. apiServer = "https://" + strings.TrimSuffix(u.Host, ":443") } apiEndpoint := apiServer + fmt.Sprintf(routeGroupListResource, routeGroupVersion) if cfg.Namespace != "" { apiEndpoint = apiServer + fmt.Sprintf(routeGroupNamespacedResource, routeGroupVersion, cfg.Namespace) } return &routeGroupSource{ cli: cli, apiServer: apiServer, namespace: cfg.Namespace, apiEndpoint: apiEndpoint, annotationFilter: cfg.AnnotationFilter, fqdnTemplate: tmpl, combineFQDNAnnotation: cfg.CombineFQDNAndAnnotation, ignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, }, nil } // AddEventHandler for routegroup is currently a no op, because we do not implement caching, yet. func (sc *routeGroupSource) AddEventHandler(_ context.Context, _ func()) {} // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all routeGroup resources on all namespaces. // Logic is ported from ingress without fqdnTemplate func (sc *routeGroupSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { rgList, err := sc.cli.getRouteGroupList(sc.apiEndpoint) if err != nil { log.Errorf("Failed to get RouteGroup list: %v", err) return nil, err } filtered, err := annotations.Filter(rgList.Items, sc.annotationFilter) if err != nil { return nil, err } endpoints := []*endpoint.Endpoint{} for _, rg := range filtered { if annotations.IsControllerMismatch(rg, types.SkipperRouteGroup) { continue } eps := sc.endpointsFromRouteGroup(rg) eps, err = fqdn.CombineWithTemplatedEndpoints( eps, sc.fqdnTemplate, sc.combineFQDNAnnotation, func() ([]*endpoint.Endpoint, error) { return sc.endpointsFromTemplate(rg) }, ) if err != nil { return nil, err } if endpoint.HasNoEmptyEndpoints(eps, types.OpenShiftRoute, rg) { continue } log.Debugf("Endpoints generated from ingress: %s/%s: %v", rg.Metadata.Namespace, rg.Metadata.Name, eps) endpoints = append(endpoints, eps...) } return MergeEndpoints(endpoints), nil } func (sc *routeGroupSource) endpointsFromTemplate(rg *routeGroup) ([]*endpoint.Endpoint, error) { // Process the whole template string var buf bytes.Buffer err := sc.fqdnTemplate.Execute(&buf, rg) if err != nil { return nil, fmt.Errorf("failed to apply template on routegroup %s/%s: %w", rg.Metadata.Namespace, rg.Metadata.Name, err) } hostnames := buf.String() resource := fmt.Sprintf("routegroup/%s/%s", rg.Metadata.Namespace, rg.Metadata.Name) // error handled in endpointsFromRouteGroup(), otherwise duplicate log ttl := annotations.TTLFromAnnotations(rg.Metadata.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(rg.Metadata.Annotations) if len(targets) == 0 { targets = targetsFromRouteGroupStatus(rg.Status) } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(rg.Metadata.Annotations) var endpoints []*endpoint.Endpoint // splits the FQDN template and removes the trailing periods hostnameList := strings.SplitSeq(strings.ReplaceAll(hostnames, " ", ""), ",") for hostname := range hostnameList { hostname = strings.TrimSuffix(hostname, ".") endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } // annotation logic ported from source/ingress.go without Spec.TLS part, because it's not supported in RouteGroup func (sc *routeGroupSource) endpointsFromRouteGroup(rg *routeGroup) []*endpoint.Endpoint { endpoints := []*endpoint.Endpoint{} resource := fmt.Sprintf("routegroup/%s/%s", rg.Metadata.Namespace, rg.Metadata.Name) ttl := annotations.TTLFromAnnotations(rg.Metadata.Annotations, resource) targets := annotations.TargetsFromTargetAnnotation(rg.Metadata.Annotations) if len(targets) == 0 { for _, lb := range rg.Status.LoadBalancer.RouteGroup { if lb.IP != "" { targets = append(targets, lb.IP) } if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } } providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(rg.Metadata.Annotations) for _, src := range rg.Spec.Hosts { if src == "" { continue } endpoints = append(endpoints, endpoint.EndpointsForHostname(src, targets, ttl, providerSpecific, setIdentifier, resource)...) } // Skip endpoints if we do not want entries from annotations if !sc.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(rg.Metadata.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } return endpoints } func targetsFromRouteGroupStatus(status routeGroupStatus) endpoint.Targets { var targets endpoint.Targets for _, lb := range status.LoadBalancer.RouteGroup { if lb.IP != "" { targets = append(targets, lb.IP) } if lb.Hostname != "" { targets = append(targets, lb.Hostname) } } return targets } type routeGroupList struct { Kind string `json:"kind"` APIVersion string `json:"apiVersion"` Metadata routeGroupListMetadata `json:"metadata"` Items []*routeGroup `json:"items"` } type routeGroupListMetadata struct { SelfLink string `json:"selfLink"` ResourceVersion string `json:"resourceVersion"` } type routeGroup struct { Metadata metav1.ObjectMeta `json:"metadata"` Spec routeGroupSpec `json:"spec"` Status routeGroupStatus `json:"status"` } func (rg *routeGroup) GetObjectMeta() metav1.Object { return rg.Metadata.GetObjectMeta() } type routeGroupSpec struct { Hosts []string `json:"hosts"` } type routeGroupStatus struct { LoadBalancer routeGroupLoadBalancerStatus `json:"loadBalancer"` } type routeGroupLoadBalancerStatus struct { RouteGroup []routeGroupLoadBalancer `json:"routeGroup"` } type routeGroupLoadBalancer struct { IP string `json:"ip,omitempty"` Hostname string `json:"hostname,omitempty"` } func (rg *routeGroup) GetAnnotations() map[string]string { return rg.Metadata.Annotations } ================================================ FILE: source/skipper_routegroup_test.go ================================================ /* Copyright 2017 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 source import ( "errors" "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" ) func createTestRouteGroup(ns, name string, annotations map[string]string, hosts []string, destinations []routeGroupLoadBalancer) *routeGroup { return &routeGroup{ Metadata: metav1.ObjectMeta{ Namespace: ns, Name: name, Annotations: annotations, }, Spec: routeGroupSpec{ Hosts: hosts, }, Status: routeGroupStatus{ LoadBalancer: routeGroupLoadBalancerStatus{ RouteGroup: destinations, }, }, } } func TestEndpointsFromRouteGroups(t *testing.T) { t.Parallel() for _, tt := range []struct { name string source *routeGroupSource rg *routeGroup want []*endpoint.Endpoint }{ { name: "Empty routegroup should return empty endpoints", source: &routeGroupSource{}, rg: &routeGroup{}, want: []*endpoint.Endpoint{}, }, { name: "Routegroup without hosts and destinations create no endpoints", source: &routeGroupSource{}, rg: createTestRouteGroup("namespace1", "rg1", nil, nil, nil), want: []*endpoint.Endpoint{}, }, { name: "Routegroup without hosts create no endpoints", source: &routeGroupSource{}, rg: createTestRouteGroup("namespace1", "rg1", nil, nil, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }), want: []*endpoint.Endpoint{}, }, { name: "Routegroup without destinations create no endpoints", source: &routeGroupSource{}, rg: createTestRouteGroup("namespace1", "rg1", nil, []string{"rg1.k8s.example"}, nil), want: []*endpoint.Endpoint{}, }, { name: "Routegroup with hosts and destinations creates an endpoint", source: &routeGroupSource{}, rg: createTestRouteGroup("namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Routegroup with hostname annotation, creates endpoints from the annotation ", source: &routeGroupSource{}, rg: createTestRouteGroup( "namespace1", "rg1", map[string]string{ annotations.HostnameKey: "my.example", }, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, { DNSName: "my.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Routegroup with hosts and destinations and ignoreHostnameAnnotation creates endpoints but ignores annotation", source: &routeGroupSource{ignoreHostnameAnnotation: true}, rg: createTestRouteGroup( "namespace1", "rg1", map[string]string{ annotations.HostnameKey: "my.example", }, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Routegroup with hosts and destinations and ttl creates an endpoint with ttl", source: &routeGroupSource{ignoreHostnameAnnotation: true}, rg: createTestRouteGroup( "namespace1", "rg1", map[string]string{ annotations.TtlKey: "2189", }, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), RecordTTL: endpoint.TTL(2189), }, }, }, { name: "Routegroup with hosts and destination IP creates an endpoint", source: &routeGroupSource{}, rg: createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { IP: "1.5.1.4", }, }, ), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets([]string{"1.5.1.4"}), }, }, }, { name: "Routegroup with hosts and destination IPv6 creates an endpoint", source: &routeGroupSource{}, rg: createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { IP: "2001:DB8::1", }, }, ), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets([]string{"2001:DB8::1"}), }, }, }, { name: "Routegroup with hosts and mixed destinations creates endpoints", source: &routeGroupSource{}, rg: createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", IP: "1.5.1.4", }, }, ), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets([]string{"1.5.1.4"}), }, { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Routegroup with hosts and mixed destinations (IPv6) creates endpoints", source: &routeGroupSource{}, rg: createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", IP: "2001:DB8::1", }, }, ), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets([]string{"2001:DB8::1"}), }, { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Routegroup with provider-specific annotation creates endpoint with provider-specific property", source: &routeGroupSource{}, rg: createTestRouteGroup( "namespace1", "rg1", map[string]string{ annotations.AWSPrefix + "weight": "10", }, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } { t.Run(tt.name, func(t *testing.T) { got := tt.source.endpointsFromRouteGroup(tt.rg) validateEndpoints(t, got, tt.want) }) } } type fakeRouteGroupClient struct { returnErr bool rg *routeGroupList } func (f *fakeRouteGroupClient) getRouteGroupList(string) (*routeGroupList, error) { if f.returnErr { return nil, errors.New("Fake route group list error") } return f.rg, nil } func TestRouteGroupsEndpoints(t *testing.T) { for _, tt := range []struct { name string source *routeGroupSource fqdnTemplate string want []*endpoint.Endpoint wantErr bool }{ { name: "Empty routegroup should return empty endpoints", source: &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{}, }, }, want: []*endpoint.Endpoint{}, wantErr: false, }, { name: "Single routegroup should return endpoints", source: &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Single routegroup with combineFQDNAnnotation with fqdn template should return endpoints from fqdnTemplate and routegroup", fqdnTemplate: "{{.Metadata.Name}}.{{.Metadata.Namespace}}.example", source: &routeGroupSource{ combineFQDNAnnotation: true, cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, { DNSName: "rg1.namespace1.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Single routegroup without, with fqdn template should return endpoints from fqdnTemplate", fqdnTemplate: "{{.Metadata.Name}}.{{.Metadata.Namespace}}.example", source: &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", nil, nil, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.namespace1.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Single routegroup without combineFQDNAnnotation with fqdn template should return endpoints not from fqdnTemplate", fqdnTemplate: "{{.Metadata.Name}}.{{.Metadata.Namespace}}.example", source: &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "Single routegroup with TTL should return endpoint with TTL", source: &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", map[string]string{ annotations.TtlKey: "2189", }, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), RecordTTL: endpoint.TTL(2189), }, }, }, { name: "Routegroup with hosts and mixed destinations creates endpoints", source: &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", IP: "1.5.1.4", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets([]string{"1.5.1.4"}), }, { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "multiple routegroups should return endpoints", source: &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace1", "rg2", nil, []string{"rg2.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace2", "rg3", nil, []string{"rg3.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace3", "rg", nil, []string{"rg.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb2.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, { DNSName: "rg2.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, { DNSName: "rg3.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, { DNSName: "rg.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb2.example.org"}), }, }, }, { name: "multiple routegroups with filter annotations should return only filtered endpoints", source: &routeGroupSource{ annotationFilter: "kubernetes.io/ingress.class=skipper", cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", map[string]string{ "kubernetes.io/ingress.class": "skipper", }, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace1", "rg2", map[string]string{ "kubernetes.io/ingress.class": "nginx", }, []string{"rg2.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace2", "rg3", map[string]string{ "kubernetes.io/ingress.class": "", }, []string{"rg3.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace3", "rg", nil, []string{"rg.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb2.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "multiple routegroups with set operation annotation filter should return only filtered endpoints", source: &routeGroupSource{ annotationFilter: "kubernetes.io/ingress.class in (nginx, skipper)", cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", map[string]string{ "kubernetes.io/ingress.class": "skipper", }, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace1", "rg2", map[string]string{ "kubernetes.io/ingress.class": "nginx", }, []string{"rg2.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace2", "rg3", map[string]string{ "kubernetes.io/ingress.class": "", }, []string{"rg3.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace3", "rg", nil, []string{"rg.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb2.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, { DNSName: "rg2.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, { name: "multiple routegroups with controller annotation filter should not return filtered endpoints", source: &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", map[string]string{ annotations.ControllerKey: annotations.ControllerValue, }, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace1", "rg2", map[string]string{ annotations.ControllerKey: "dns", }, []string{"rg2.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), createTestRouteGroup( "namespace2", "rg3", nil, []string{"rg3.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), }, }, }, }, want: []*endpoint.Endpoint{ { DNSName: "rg1.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, { DNSName: "rg3.k8s.example", RecordType: endpoint.RecordTypeCNAME, Targets: endpoint.Targets([]string{"lb.example.org"}), }, }, }, } { t.Run(tt.name, func(t *testing.T) { if tt.fqdnTemplate != "" { tmpl, err := fqdn.ParseTemplate(tt.fqdnTemplate) if err != nil { t.Fatalf("Failed to parse template: %v", err) } tt.source.fqdnTemplate = tmpl } got, err := tt.source.Endpoints(t.Context()) if err != nil && !tt.wantErr { t.Errorf("Got error, but does not want to get an error: %v", err) } if tt.wantErr && err == nil { t.Fatal("Got no error, but we want to get an error") } validateEndpoints(t, got, tt.want) }) } } func TestResourceLabelIsSet(t *testing.T) { source := &routeGroupSource{ cli: &fakeRouteGroupClient{ rg: &routeGroupList{ Items: []*routeGroup{ createTestRouteGroup( "namespace1", "rg1", nil, []string{"rg1.k8s.example"}, []routeGroupLoadBalancer{ { Hostname: "lb.example.org", }, }, ), }, }, }, } got, _ := source.Endpoints(t.Context()) for _, ep := range got { if _, ok := ep.Labels[endpoint.ResourceLabelKey]; !ok { t.Errorf("Failed to set resource label on ep %v", ep) } } } ================================================ FILE: source/source.go ================================================ /* Copyright 2017 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 source import ( "context" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) const ( EndpointsTypeNodeExternalIP = "NodeExternalIP" EndpointsTypeHostIP = "HostIP" ) // Source defines the interface Endpoint sources should implement. type Source interface { Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) // AddEventHandler adds an event handler that should be triggered if something in source changes AddEventHandler(context.Context, func()) } type kubeObject interface { runtime.Object metav1.Object } func getAccessFromAnnotations(input map[string]string) string { return input[annotations.AccessKey] } func getEndpointsTypeFromAnnotations(annots map[string]string) string { return annots[annotations.EndpointsTypeKey] } func getLabelSelector(annotationFilter string) (labels.Selector, error) { labelSelector, err := metav1.ParseToLabelSelector(annotationFilter) if err != nil { return nil, err } return metav1.LabelSelectorAsSelector(labelSelector) } func matchLabelSelector(selector labels.Selector, srcAnnotations map[string]string) bool { return selector.Matches(labels.Set(srcAnnotations)) } type eventHandlerFunc func() func (fn eventHandlerFunc) OnAdd(_ any, _ bool) { fn() } func (fn eventHandlerFunc) OnUpdate(_, _ any) { fn() } func (fn eventHandlerFunc) OnDelete(_ any) { fn() } ================================================ FILE: source/source_test.go ================================================ /* Copyright 2017 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 source import ( "testing" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/labels" ) func TestGetLabelSelector(t *testing.T) { tests := []struct { name string annotationFilter string expectError bool expectedSelector string }{ { name: "Valid label selector", annotationFilter: "key1=value1,key2=value2", expectedSelector: "key1=value1,key2=value2", }, { name: "Invalid label selector", annotationFilter: "key1==value1", expectedSelector: "key1=value1", }, { name: "Empty label selector", annotationFilter: "", expectedSelector: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { selector, err := getLabelSelector(tt.annotationFilter) assert.NoError(t, err) assert.Equal(t, tt.expectedSelector, selector.String()) }) } } func TestMatchLabelSelector(t *testing.T) { tests := []struct { name string selector labels.Selector srcAnnotations map[string]string expectedMatch bool }{ { name: "Matching label selector", selector: labels.SelectorFromSet(labels.Set{"key1": "value1"}), srcAnnotations: map[string]string{"key1": "value1", "key2": "value2"}, expectedMatch: true, }, { name: "Non-matching label selector", selector: labels.SelectorFromSet(labels.Set{"key1": "value1"}), srcAnnotations: map[string]string{"key2": "value2"}, expectedMatch: false, }, { name: "Empty label selector", selector: labels.NewSelector(), srcAnnotations: map[string]string{"key1": "value1"}, expectedMatch: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := matchLabelSelector(tt.selector, tt.srcAnnotations) assert.Equal(t, tt.expectedMatch, result) }) } } ================================================ FILE: source/store.go ================================================ /* Copyright 2017 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 source import ( "context" "errors" "fmt" "os" "sync" "time" openshift "github.com/openshift/client-go/route/clientset/versioned" log "github.com/sirupsen/logrus" istioclient "istio.io/client-go/pkg/clientset/versioned" "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" "sigs.k8s.io/external-dns/pkg/apis/externaldns" kubeclient "sigs.k8s.io/external-dns/pkg/client" "sigs.k8s.io/external-dns/source/types" ) // ErrSourceNotFound is returned when a requested source doesn't exist. var ErrSourceNotFound = errors.New("source not found") // Config holds shared configuration options for all Sources. // This struct centralizes all source-related configuration to avoid parameter proliferation // in individual source constructors. It follows the configuration pattern where a single // config object is passed rather than individual parameters. // // Common Configuration Fields: // - Namespace: Target namespace for source operations // - AnnotationFilter: Filter sources by annotation patterns // - LabelFilter: Filter sources by label selectors // - FQDNTemplate: Template for generating fully qualified domain names // - CombineFQDNAndAnnotation: Whether to combine FQDN template with annotations // - IgnoreHostnameAnnotation: Whether to ignore hostname annotations // // The config is created from externaldns.Config via NewSourceConfig() which handles // type conversions and validation. type Config struct { Namespace string AnnotationFilter string LabelFilter labels.Selector IngressClassNames []string FQDNTemplate string TargetTemplate string FQDNTargetTemplate string CombineFQDNAndAnnotation bool IgnoreHostnameAnnotation bool IgnoreNonHostNetworkPods bool IgnoreIngressTLSSpec bool IgnoreIngressRulesSpec bool ListenEndpointEvents bool GatewayName string GatewayNamespace string GatewayLabelFilter string Compatibility string Provider string PodSourceDomain string PublishInternal bool PublishHostIP bool AlwaysPublishNotReadyAddresses bool ConnectorServer string CRDSourceAPIVersion string CRDSourceKind string KubeConfig string APIServerURL string ServiceTypeFilter []string GlooNamespaces []string SkipperRouteGroupVersion string RequestTimeout time.Duration DefaultTargets []string ForceDefaultTargets bool OCPRouterName string UpdateEvents bool ResolveLoadBalancerHostname bool TraefikEnableLegacy bool TraefikDisableNew bool ExcludeUnschedulable bool ExposeInternalIPv6 bool ExcludeTargetNets []string TargetNetFilter []string NAT64Networks []string MinTTL time.Duration UnstructuredResources []string PreferAlias bool sources []string // clientGen is lazily initialized on first access for efficiency clientGen *SingletonClientGenerator clientGenOnce sync.Once } func NewSourceConfig(cfg *externaldns.Config) *Config { // error is explicitly ignored because the filter is already validated in validation.ValidateConfig labelSelector, _ := labels.Parse(cfg.LabelFilter) return &Config{ Namespace: cfg.Namespace, AnnotationFilter: cfg.AnnotationFilter, LabelFilter: labelSelector, IngressClassNames: cfg.IngressClassNames, CombineFQDNAndAnnotation: cfg.CombineFQDNAndAnnotation, IgnoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, IgnoreNonHostNetworkPods: cfg.IgnoreNonHostNetworkPods, IgnoreIngressTLSSpec: cfg.IgnoreIngressTLSSpec, IgnoreIngressRulesSpec: cfg.IgnoreIngressRulesSpec, ListenEndpointEvents: cfg.ListenEndpointEvents, GatewayName: cfg.GatewayName, GatewayNamespace: cfg.GatewayNamespace, GatewayLabelFilter: cfg.GatewayLabelFilter, Compatibility: cfg.Compatibility, PodSourceDomain: cfg.PodSourceDomain, PublishInternal: cfg.PublishInternal, PublishHostIP: cfg.PublishHostIP, Provider: cfg.Provider, AlwaysPublishNotReadyAddresses: cfg.AlwaysPublishNotReadyAddresses, ConnectorServer: cfg.ConnectorSourceServer, CRDSourceAPIVersion: cfg.CRDSourceAPIVersion, CRDSourceKind: cfg.CRDSourceKind, KubeConfig: cfg.KubeConfig, APIServerURL: cfg.APIServerURL, ServiceTypeFilter: cfg.ServiceTypeFilter, GlooNamespaces: cfg.GlooNamespaces, SkipperRouteGroupVersion: cfg.SkipperRouteGroupVersion, RequestTimeout: cfg.RequestTimeout, DefaultTargets: cfg.DefaultTargets, ForceDefaultTargets: cfg.ForceDefaultTargets, OCPRouterName: cfg.OCPRouterName, UpdateEvents: cfg.UpdateEvents, ResolveLoadBalancerHostname: cfg.ResolveServiceLoadBalancerHostname, TraefikEnableLegacy: cfg.TraefikEnableLegacy, TraefikDisableNew: cfg.TraefikDisableNew, ExcludeUnschedulable: cfg.ExcludeUnschedulable, ExposeInternalIPv6: cfg.ExposeInternalIPV6, ExcludeTargetNets: cfg.ExcludeTargetNets, TargetNetFilter: cfg.TargetNetFilter, NAT64Networks: cfg.NAT64Networks, MinTTL: cfg.MinTTL, UnstructuredResources: cfg.UnstructuredResources, FQDNTemplate: cfg.FQDNTemplate, TargetTemplate: cfg.TargetTemplate, FQDNTargetTemplate: cfg.FQDNTargetTemplate, PreferAlias: cfg.PreferAlias, sources: cfg.Sources, } } // ClientGenerator returns a SingletonClientGenerator from this Config's connection settings. // The generator is created once and cached for subsequent calls. // This ensures consistent Kubernetes client creation across all sources using this configuration. // // The timeout behavior is special-cased: when UpdateEvents is true, the timeout is set to 0 // (no timeout) to allow long-running watch operations for event-driven source updates. func (cfg *Config) ClientGenerator() *SingletonClientGenerator { cfg.clientGenOnce.Do(func() { cfg.clientGen = &SingletonClientGenerator{ KubeConfig: cfg.KubeConfig, APIServerURL: cfg.APIServerURL, RequestTimeout: func() time.Duration { if cfg.UpdateEvents { return 0 } return cfg.RequestTimeout }(), } }) return cfg.clientGen } // ClientGenerator provides clients for various Kubernetes APIs and external services. // This interface abstracts client creation and enables dependency injection for testing. // It uses the singleton pattern to ensure only one instance of each client is created // and reused across multiple source instances. // // Supported Client Types: // - KubeClient: Standard Kubernetes API client // - GatewayClient: Gateway API client for Gateway resources // - IstioClient: Istio service mesh client // - DynamicKubernetesClient: Dynamic client for custom resources // - OpenShiftClient: OpenShift-specific client for Route resources // - RESTConfig: Instrumented REST config for creating custom clients // // The singleton behavior is implemented in SingletonClientGenerator which uses // sync.Once to guarantee single initialization of each client type. type ClientGenerator interface { KubeClient() (kubernetes.Interface, error) GatewayClient() (gateway.Interface, error) IstioClient() (istioclient.Interface, error) DynamicKubernetesClient() (dynamic.Interface, error) OpenShiftClient() (openshift.Interface, error) RESTConfig() (*rest.Config, error) } // SingletonClientGenerator stores provider clients and guarantees that only one instance of each client // will be generated throughout the application lifecycle. // // Thread Safety: Uses sync.Once for each client type to ensure thread-safe initialization. // This is important because external-dns may create multiple sources concurrently. // // Memory Efficiency: Prevents creating multiple instances of expensive client objects // that maintain their own connection pools and caches. // // Configuration: Clients are configured using KubeConfig, APIServerURL, and RequestTimeout // which are set during SingletonClientGenerator initialization. // // TODO: Fix error handling pattern in client methods. Current implementation has a bug where // errors are only returned on the first call due to sync.Once behavior. If initialization fails // on the first call, subsequent calls return (nil, nil) instead of (nil, originalError), which // can lead to nil pointer dereferences. Solution: Store error in a field alongside the client, // similar to how the client itself is stored. Example: // // type SingletonClientGenerator struct { // restConfig *rest.Config // restConfigErr error // Store error persistently // restConfigOnce sync.Once // } // // func (p *SingletonClientGenerator) RESTConfig() (*rest.Config, error) { // p.restConfigOnce.Do(func() { // p.restConfig, p.restConfigErr = kubeclient.InstrumentedRESTConfig(...) // }) // return p.restConfig, p.restConfigErr // Return stored error // } // // This pattern should be applied to all client methods: KubeClient, GatewayClient, // DynamicKubernetesClient, OpenShiftClient, and RESTConfig. type SingletonClientGenerator struct { KubeConfig string APIServerURL string RequestTimeout time.Duration restConfig *rest.Config kubeClient kubernetes.Interface gatewayClient gateway.Interface istioClient *istioclient.Clientset dynKubeClient dynamic.Interface openshiftClient openshift.Interface restConfigOnce sync.Once kubeOnce sync.Once gatewayOnce sync.Once istioOnce sync.Once dynCliOnce sync.Once openshiftOnce sync.Once } // KubeClient generates a kube client if it was not created before func (p *SingletonClientGenerator) KubeClient() (kubernetes.Interface, error) { var err error p.kubeOnce.Do(func() { p.kubeClient, err = kubeclient.NewKubeClient(p.KubeConfig, p.APIServerURL, p.RequestTimeout) }) return p.kubeClient, err } // RESTConfig generates an instrumented REST config if it was not created before. // The config includes request timeout handling and metrics instrumentation. // This is useful for sources that need to create custom clients (e.g., controller-runtime clients). func (p *SingletonClientGenerator) RESTConfig() (*rest.Config, error) { var err error p.restConfigOnce.Do(func() { p.restConfig, err = kubeclient.InstrumentedRESTConfig(p.KubeConfig, p.APIServerURL, p.RequestTimeout) }) return p.restConfig, err } // GatewayClient generates a gateway client if it was not created before func (p *SingletonClientGenerator) GatewayClient() (gateway.Interface, error) { var err error p.gatewayOnce.Do(func() { var config *rest.Config config, err = p.RESTConfig() if err != nil { return } p.gatewayClient, err = gateway.NewForConfig(config) if err != nil { return } log.Infof("Created GatewayAPI client %s", config.Host) }) return p.gatewayClient, err } // IstioClient generates an istio go client if it was not created before func (p *SingletonClientGenerator) IstioClient() (istioclient.Interface, error) { var err error p.istioOnce.Do(func() { p.istioClient, err = NewIstioClient(p.KubeConfig, p.APIServerURL) }) return p.istioClient, err } // DynamicKubernetesClient generates a dynamic client if it was not created before func (p *SingletonClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) { var err error p.dynCliOnce.Do(func() { var config *rest.Config config, err = p.RESTConfig() if err != nil { return } p.dynKubeClient, err = dynamic.NewForConfig(config) if err != nil { return } log.Infof("Created Dynamic Kubernetes client %s", config.Host) }) return p.dynKubeClient, err } // OpenShiftClient generates an openshift client if it was not created before func (p *SingletonClientGenerator) OpenShiftClient() (openshift.Interface, error) { var err error p.openshiftOnce.Do(func() { var config *rest.Config config, err = p.RESTConfig() if err != nil { return } p.openshiftClient, err = openshift.NewForConfig(config) if err != nil { return } log.Infof("Created OpenShift client %s", config.Host) }) return p.openshiftClient, err } // ByNames returns multiple Sources given multiple names. func ByNames(ctx context.Context, cfg *Config, p ClientGenerator) ([]Source, error) { sources := make([]Source, 0, len(cfg.sources)) for _, name := range cfg.sources { source, err := BuildWithConfig(ctx, name, p, cfg) if err != nil { return nil, err } sources = append(sources, source) } return sources, nil } // BuildWithConfig creates a Source implementation using the factory pattern. // This function serves as the central registry for all available source types. // // Source Selection: Uses a string identifier to determine which source type to create. // This allows for runtime configuration and easy extension with new source types. // // Error Handling: Returns ErrSourceNotFound for unsupported source types, // allowing callers to handle unknown sources gracefully. // // Supported Source Types: // - "node": Kubernetes nodes // - "service": Kubernetes services // - "ingress": Kubernetes ingresses // - "pod": Kubernetes pods // - "gateway-*": Gateway API resources (httproute, grpcroute, tlsroute, tcproute, udproute) // - "istio-*": Istio resources (gateway, virtualservice) // - "ambassador-host": Ambassador Host resources // - "contour-httpproxy": Contour HTTPProxy resources // - "gloo-proxy": Gloo proxy resources // - "traefik-proxy": Traefik proxy resources // - "openshift-route": OpenShift Route resources // - "crd": Custom Resource Definitions // - "skipper-routegroup": Skipper RouteGroup resources // - "kong-tcpingress": Kong TCP Ingress resources // - "f5-*": F5 resources (virtualserver, transportserver) // - "fake": Fake source for testing // - "connector": Connector source for external systems // // Design Note: Gateway API sources use a different pattern (direct constructor calls) // because they have simpler initialization requirements. func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg *Config) (Source, error) { switch source { case types.Node: return buildNodeSource(ctx, p, cfg) case types.Service: return buildServiceSource(ctx, p, cfg) case types.Ingress: return buildIngressSource(ctx, p, cfg) case types.Pod: return buildPodSource(ctx, p, cfg) case types.GatewayHttpRoute: return NewGatewayHTTPRouteSource(ctx, p, cfg) case types.GatewayGrpcRoute: return NewGatewayGRPCRouteSource(ctx, p, cfg) case types.GatewayTlsRoute: return NewGatewayTLSRouteSource(ctx, p, cfg) case types.GatewayTcpRoute: return NewGatewayTCPRouteSource(ctx, p, cfg) case types.GatewayUdpRoute: return NewGatewayUDPRouteSource(ctx, p, cfg) case types.IstioGateway: return buildIstioGatewaySource(ctx, p, cfg) case types.IstioVirtualService: return buildIstioVirtualServiceSource(ctx, p, cfg) case types.AmbassadorHost: return buildAmbassadorHostSource(ctx, p, cfg) case types.ContourHTTPProxy: return buildContourHTTPProxySource(ctx, p, cfg) case types.GlooProxy: return buildGlooProxySource(ctx, p, cfg) case types.TraefikProxy: return buildTraefikProxySource(ctx, p, cfg) case types.OpenShiftRoute: return buildOpenShiftRouteSource(ctx, p, cfg) case types.Fake: return NewFakeSource(cfg.FQDNTemplate) case types.Connector: return NewConnectorSource(cfg.ConnectorServer) case types.CRD: return buildCRDSource(ctx, p, cfg) case types.SkipperRouteGroup: return buildSkipperRouteGroupSource(ctx, cfg) case types.KongTCPIngress: return buildKongTCPIngressSource(ctx, p, cfg) case types.F5VirtualServer: return buildF5VirtualServerSource(ctx, p, cfg) case types.F5TransportServer: return buildF5TransportServerSource(ctx, p, cfg) case types.Unstructured: return buildUnstructuredSource(ctx, p, cfg) } return nil, ErrSourceNotFound } // Source Builder Functions // // The following functions follow a standardized pattern for creating source instances. // This standardization improves code consistency, maintainability, and readability. // // Standardized Function Signature Pattern: // // func buildXXXSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) // // Standardized Constructor Parameter Pattern (where applicable): // 1. ctx (context.Context) - Always first when supported by the source constructor // 2. client(s) (kubernetes.Interface, dynamic.Interface, etc.) - Kubernetes clients // 3. namespace (string) - Target namespace for the source // 4. annotationFilter (string) - Filter for annotations // 5. labelFilter (labels.Selector) - Filter for labels (when applicable) // 6. fqdnTemplate (string) - FQDN template for DNS record generation // 7. combineFQDNAndAnnotation (bool) - Whether to combine FQDN template with annotations // 8. ...other parameters - Source-specific parameters in logical order // // Design Principles: // - Each source type has its own specific requirements and dependencies // - Separating build functions allows for clearer code organization and easier maintenance // - Individual functions enable straightforward error handling and independent testing // - Modularity makes it easier to add new source types or modify existing ones // - Consistent parameter ordering reduces cognitive load when working with multiple sources // // Note: Some sources may deviate from the standard pattern due to their unique requirements // (e.g., RouteGroupSource doesn't use ClientGenerator, GlooSource doesn't accept context) // buildNodeSource creates a Node source for exposing node information as DNS records. // Follows standard pattern: ctx, client, annotationFilter, fqdnTemplate, labelFilter, ...other func buildNodeSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { client, err := p.KubeClient() if err != nil { return nil, err } return NewNodeSource(ctx, client, cfg) } // buildServiceSource creates a Service source for exposing Kubernetes services as DNS records. // Follows standard pattern: ctx, client, namespace, annotationFilter, fqdnTemplate, ...other func buildServiceSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { client, err := p.KubeClient() if err != nil { return nil, err } return NewServiceSource(ctx, client, cfg) } // buildIngressSource creates an Ingress source for exposing Kubernetes ingresses as DNS records. // Follows standard pattern: ctx, client, namespace, annotationFilter, fqdnTemplate, ...other func buildIngressSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { client, err := p.KubeClient() if err != nil { return nil, err } return NewIngressSource(ctx, client, cfg) } // buildPodSource creates a Pod source for exposing Kubernetes pods as DNS records. // Follows standard pattern: ctx, client, namespace, ...other (no annotation/label filters) func buildPodSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { client, err := p.KubeClient() if err != nil { return nil, err } return NewPodSource(ctx, client, cfg) } // buildIstioGatewaySource creates an Istio Gateway source for exposing Istio gateways as DNS records. // Requires both Kubernetes and Istio clients. Follows standard parameter pattern. func buildIstioGatewaySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubernetesClient, err := p.KubeClient() if err != nil { return nil, err } istioClient, err := p.IstioClient() if err != nil { return nil, err } return NewIstioGatewaySource(ctx, kubernetesClient, istioClient, cfg) } // buildIstioVirtualServiceSource creates an Istio VirtualService source for exposing virtual services as DNS records. // Requires both Kubernetes and Istio clients. Follows standard parameter pattern. func buildIstioVirtualServiceSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubernetesClient, err := p.KubeClient() if err != nil { return nil, err } istioClient, err := p.IstioClient() if err != nil { return nil, err } return NewIstioVirtualServiceSource(ctx, kubernetesClient, istioClient, cfg) } func buildAmbassadorHostSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubernetesClient, err := p.KubeClient() if err != nil { return nil, err } dynamicClient, err := p.DynamicKubernetesClient() if err != nil { return nil, err } return NewAmbassadorHostSource(ctx, dynamicClient, kubernetesClient, cfg) } func buildContourHTTPProxySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { dynamicClient, err := p.DynamicKubernetesClient() if err != nil { return nil, err } return NewContourHTTPProxySource(ctx, dynamicClient, cfg) } // buildGlooProxySource creates a Gloo source for exposing Gloo proxies as DNS records. // Requires both dynamic and standard Kubernetes clients. // Note: Does not accept context parameter in constructor (legacy design). func buildGlooProxySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubernetesClient, err := p.KubeClient() if err != nil { return nil, err } dynamicClient, err := p.DynamicKubernetesClient() if err != nil { return nil, err } return NewGlooSource(ctx, dynamicClient, kubernetesClient, cfg) } func buildTraefikProxySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubernetesClient, err := p.KubeClient() if err != nil { return nil, err } dynamicClient, err := p.DynamicKubernetesClient() if err != nil { return nil, err } return NewTraefikSource(ctx, dynamicClient, kubernetesClient, cfg) } func buildOpenShiftRouteSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { ocpClient, err := p.OpenShiftClient() if err != nil { return nil, err } return NewOcpRouteSource(ctx, ocpClient, cfg) } // buildCRDSource creates a CRD source for exposing custom resources as DNS records. // Uses a specialized CRD client created via NewCRDClientForAPIVersionKind. // Parameter order: crdClient, namespace, kind, annotationFilter, labelFilter, scheme, updateEvents func buildCRDSource(_ context.Context, p ClientGenerator, cfg *Config) (Source, error) { client, err := p.KubeClient() if err != nil { return nil, err } crdClient, scheme, err := NewCRDClientForAPIVersionKind(client, cfg) if err != nil { return nil, err } return NewCRDSource(crdClient, cfg, scheme) } // buildSkipperRouteGroupSource creates a Skipper RouteGroup source for exposing route groups as DNS records. // Special case: Does not use ClientGenerator pattern, instead manages its own authentication. // Retrieves bearer token from REST config for API server authentication. func buildSkipperRouteGroupSource(_ context.Context, cfg *Config) (Source, error) { apiServerURL := cfg.APIServerURL tokenPath := "" token := "" restConfig, err := kubeclient.GetRestConfig(cfg.KubeConfig, cfg.APIServerURL) if err == nil { apiServerURL = restConfig.Host tokenPath = restConfig.BearerTokenFile token = restConfig.BearerToken } return NewRouteGroupSource(cfg, token, tokenPath, apiServerURL) } func buildKongTCPIngressSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubernetesClient, err := p.KubeClient() if err != nil { return nil, err } dynamicClient, err := p.DynamicKubernetesClient() if err != nil { return nil, err } return NewKongTCPIngressSource(ctx, dynamicClient, kubernetesClient, cfg) } func buildF5VirtualServerSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubernetesClient, err := p.KubeClient() if err != nil { return nil, err } dynamicClient, err := p.DynamicKubernetesClient() if err != nil { return nil, err } return NewF5VirtualServerSource(ctx, dynamicClient, kubernetesClient, cfg) } func buildF5TransportServerSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubernetesClient, err := p.KubeClient() if err != nil { return nil, err } dynamicClient, err := p.DynamicKubernetesClient() if err != nil { return nil, err } return NewF5TransportServerSource(ctx, dynamicClient, kubernetesClient, cfg) } func buildUnstructuredSource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { kubeClient, err := p.KubeClient() if err != nil { return nil, err } dynamicClient, err := p.DynamicKubernetesClient() if err != nil { return nil, err } return NewUnstructuredFQDNSource(ctx, dynamicClient, kubeClient, cfg) } // NewIstioClient returns a new Istio client object. It uses the configured // KubeConfig attribute to connect to the cluster. If KubeConfig isn't provided // it defaults to using the recommended default. // NB: Istio controls the creation of the underlying Kubernetes client, so we // have no ability to tack on transport wrappers (e.g., Prometheus request // wrappers) to the client's config at this level. Furthermore, the Istio client // constructor does not expose the ability to override the Kubernetes API server endpoint, // so the apiServerURL config attribute has no effect. func NewIstioClient(kubeConfig string, apiServerURL string) (*istioclient.Clientset, error) { if kubeConfig == "" { if _, err := os.Stat(clientcmd.RecommendedHomeFile); err == nil { kubeConfig = clientcmd.RecommendedHomeFile } } restCfg, err := clientcmd.BuildConfigFromFlags(apiServerURL, kubeConfig) if err != nil { return nil, err } ic, err := istioclient.NewForConfig(restCfg) if err != nil { return nil, fmt.Errorf("failed to create istio client: %w", err) } return ic, nil } ================================================ FILE: source/store_test.go ================================================ /* Copyright 2017 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 source import ( "context" "errors" "testing" "time" openshift "github.com/openshift/client-go/route/clientset/versioned" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" istioclient "istio.io/client-go/pkg/clientset/versioned" istiofake "istio.io/client-go/pkg/clientset/versioned/fake" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" fakeDynamic "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes" fakeKube "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/rest" gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" "sigs.k8s.io/external-dns/source/types" ) type MockClientGenerator struct { mock.Mock kubeClient kubernetes.Interface gatewayClient gateway.Interface istioClient istioclient.Interface dynamicKubernetesClient dynamic.Interface openshiftClient openshift.Interface } func (m *MockClientGenerator) KubeClient() (kubernetes.Interface, error) { args := m.Called() if args.Error(1) == nil { m.kubeClient = args.Get(0).(kubernetes.Interface) return m.kubeClient, nil } return nil, args.Error(1) } func (m *MockClientGenerator) GatewayClient() (gateway.Interface, error) { args := m.Called() if args.Error(1) != nil { return nil, args.Error(1) } m.gatewayClient = args.Get(0).(gateway.Interface) return m.gatewayClient, nil } func (m *MockClientGenerator) IstioClient() (istioclient.Interface, error) { args := m.Called() if args.Error(1) == nil { m.istioClient = args.Get(0).(istioclient.Interface) return m.istioClient, nil } return nil, args.Error(1) } func (m *MockClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) { args := m.Called() if args.Error(1) == nil { m.dynamicKubernetesClient = args.Get(0).(dynamic.Interface) return m.dynamicKubernetesClient, nil } return nil, args.Error(1) } func (m *MockClientGenerator) OpenShiftClient() (openshift.Interface, error) { args := m.Called() if args.Error(1) == nil { m.openshiftClient = args.Get(0).(openshift.Interface) return m.openshiftClient, nil } return nil, args.Error(1) } func (m *MockClientGenerator) RESTConfig() (*rest.Config, error) { args := m.Called() if args.Error(1) == nil { return args.Get(0).(*rest.Config), nil } return nil, args.Error(1) } type ByNamesTestSuite struct { suite.Suite } func (suite *ByNamesTestSuite) TestAllInitialized() { mockClientGenerator := new(MockClientGenerator) mockClientGenerator.On("KubeClient").Return(fakeKube.NewSimpleClientset(), nil) mockClientGenerator.On("IstioClient").Return(istiofake.NewSimpleClientset(), nil) mockClientGenerator.On("DynamicKubernetesClient").Return(fakeDynamic.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ { Group: "projectcontour.io", Version: "v1", Resource: "httpproxies", }: "HTTPPRoxiesList", { Group: "contour.heptio.com", Version: "v1beta1", Resource: "tcpingresses", }: "TCPIngressesList", { Group: "configuration.konghq.com", Version: "v1beta1", Resource: "tcpingresses", }: "TCPIngressesList", { Group: "cis.f5.com", Version: "v1", Resource: "virtualservers", }: "VirtualServersList", { Group: "cis.f5.com", Version: "v1", Resource: "transportservers", }: "TransportServersList", { Group: "traefik.containo.us", Version: "v1alpha1", Resource: "ingressroutes", }: "IngressRouteList", { Group: "traefik.containo.us", Version: "v1alpha1", Resource: "ingressroutetcps", }: "IngressRouteTCPList", { Group: "traefik.containo.us", Version: "v1alpha1", Resource: "ingressrouteudps", }: "IngressRouteUDPList", { Group: "traefik.io", Version: "v1alpha1", Resource: "ingressroutes", }: "IngressRouteList", { Group: "traefik.io", Version: "v1alpha1", Resource: "ingressroutetcps", }: "IngressRouteTCPList", { Group: "traefik.io", Version: "v1alpha1", Resource: "ingressrouteudps", }: "IngressRouteUDPList", }), nil) ss := []string{ types.Service, types.Ingress, types.IstioGateway, types.ContourHTTPProxy, types.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer, types.TraefikProxy, types.Fake, } sources, err := ByNames(context.TODO(), &Config{ sources: ss, }, mockClientGenerator) suite.NoError(err, "should not generate errors") suite.Len(sources, 9, "should generate all nine sources") } func (suite *ByNamesTestSuite) TestOnlyFake() { mockClientGenerator := new(MockClientGenerator) mockClientGenerator.On("KubeClient").Return(fakeKube.NewClientset(), nil) sources, err := ByNames(context.TODO(), &Config{ sources: []string{types.Fake}, }, mockClientGenerator) suite.NoError(err, "should not generate errors") suite.Len(sources, 1, "should generate fake source") suite.Nil(mockClientGenerator.kubeClient, "client should not be created") } func (suite *ByNamesTestSuite) TestSourceNotFound() { mockClientGenerator := new(MockClientGenerator) mockClientGenerator.On("KubeClient").Return(fakeKube.NewClientset(), nil) sources, err := ByNames(context.TODO(), &Config{ sources: []string{"foo"}, }, mockClientGenerator) suite.Equal(err, ErrSourceNotFound, "should return source not found") suite.Empty(sources, "should not returns any source") } func (suite *ByNamesTestSuite) TestKubeClientFails() { mockClientGenerator := new(MockClientGenerator) mockClientGenerator.On("KubeClient").Return(nil, errors.New("foo")) sourceUnderTest := []string{ types.Node, types.Service, types.Ingress, types.Pod, types.IstioGateway, types.IstioVirtualService, types.AmbassadorHost, types.GlooProxy, types.TraefikProxy, types.CRD, types.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer, } for _, source := range sourceUnderTest { _, err := ByNames(context.TODO(), &Config{ sources: []string{source}, }, mockClientGenerator) suite.Error(err, source+" should return an error if kubernetes client cannot be created") } } func (suite *ByNamesTestSuite) TestIstioClientFails() { mockClientGenerator := new(MockClientGenerator) mockClientGenerator.On("KubeClient").Return(fakeKube.NewSimpleClientset(), nil) mockClientGenerator.On("IstioClient").Return(nil, errors.New("foo")) mockClientGenerator.On("DynamicKubernetesClient").Return(nil, errors.New("foo")) sourcesDependentOnIstioClient := []string{types.IstioGateway, types.IstioVirtualService} for _, source := range sourcesDependentOnIstioClient { _, err := ByNames(context.TODO(), &Config{ sources: []string{source}, }, mockClientGenerator) suite.Error(err, source+" should return an error if istio client cannot be created") } } func (suite *ByNamesTestSuite) TestDynamicKubernetesClientFails() { mockClientGenerator := new(MockClientGenerator) mockClientGenerator.On("KubeClient").Return(fakeKube.NewClientset(), nil) mockClientGenerator.On("IstioClient").Return(istiofake.NewSimpleClientset(), nil) mockClientGenerator.On("DynamicKubernetesClient").Return(nil, errors.New("foo")) sourcesDependentOnDynamicKubernetesClient := []string{ types.AmbassadorHost, types.ContourHTTPProxy, types.GlooProxy, types.TraefikProxy, types.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer, } for _, source := range sourcesDependentOnDynamicKubernetesClient { _, err := ByNames(context.TODO(), &Config{ sources: []string{source}, }, mockClientGenerator) suite.Error(err, source+" should return an error if dynamic kubernetes client cannot be created") } } func TestByNames(t *testing.T) { suite.Run(t, new(ByNamesTestSuite)) } type minimalMockClientGenerator struct{} var errMock = errors.New("mock not implemented") func (m *minimalMockClientGenerator) KubeClient() (kubernetes.Interface, error) { return nil, errMock } func (m *minimalMockClientGenerator) GatewayClient() (gateway.Interface, error) { return nil, errMock } func (m *minimalMockClientGenerator) IstioClient() (istioclient.Interface, error) { return nil, errMock } func (m *minimalMockClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) { return nil, errMock } func (m *minimalMockClientGenerator) OpenShiftClient() (openshift.Interface, error) { return nil, errMock } func (m *minimalMockClientGenerator) RESTConfig() (*rest.Config, error) { return nil, errMock } func TestBuildWithConfig_InvalidSource(t *testing.T) { ctx := t.Context() p := &minimalMockClientGenerator{} cfg := &Config{LabelFilter: labels.NewSelector()} src, err := BuildWithConfig(ctx, "not-a-source", p, cfg) if src != nil { t.Errorf("expected nil source for invalid type, got: %v", src) } if !errors.Is(err, ErrSourceNotFound) { t.Errorf("expected ErrSourceNotFound, got: %v", err) } } func TestConfig_ClientGenerator(t *testing.T) { cfg := &Config{ KubeConfig: "/path/to/kubeconfig", APIServerURL: "https://api.example.com", RequestTimeout: 30 * time.Second, UpdateEvents: false, } gen := cfg.ClientGenerator() assert.Equal(t, "/path/to/kubeconfig", gen.KubeConfig) assert.Equal(t, "https://api.example.com", gen.APIServerURL) assert.Equal(t, 30*time.Second, gen.RequestTimeout) } func TestConfig_ClientGenerator_UpdateEvents(t *testing.T) { cfg := &Config{ KubeConfig: "/path/to/kubeconfig", APIServerURL: "https://api.example.com", RequestTimeout: 30 * time.Second, UpdateEvents: true, // Special case } gen := cfg.ClientGenerator() assert.Equal(t, time.Duration(0), gen.RequestTimeout, "UpdateEvents should set timeout to 0") } func TestConfig_ClientGenerator_Caching(t *testing.T) { cfg := &Config{ KubeConfig: "/path/to/kubeconfig", APIServerURL: "https://api.example.com", RequestTimeout: 30 * time.Second, UpdateEvents: false, } // Call ClientGenerator twice gen1 := cfg.ClientGenerator() gen2 := cfg.ClientGenerator() // Should return the same instance (cached) assert.Same(t, gen1, gen2, "ClientGenerator should return the same cached instance") } // TestSingletonClientGenerator_RESTConfig_TimeoutPropagation verifies timeout configuration func TestSingletonClientGenerator_RESTConfig_TimeoutPropagation(t *testing.T) { testCases := []struct { name string requestTimeout time.Duration }{ { name: "30 second timeout", requestTimeout: 30 * time.Second, }, { name: "60 second timeout", requestTimeout: 60 * time.Second, }, { name: "zero timeout (for watches)", requestTimeout: 0, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { gen := &SingletonClientGenerator{ KubeConfig: "", APIServerURL: "", RequestTimeout: tc.requestTimeout, } // Verify the generator was configured with correct timeout assert.Equal(t, tc.requestTimeout, gen.RequestTimeout, "SingletonClientGenerator should have the configured RequestTimeout") config, err := gen.RESTConfig() // Even if config creation failed, verify the timeout was set in generator assert.Equal(t, tc.requestTimeout, gen.RequestTimeout, "RequestTimeout should remain unchanged after RESTConfig() call") // If config was successfully created, verify timeout propagated correctly if err == nil { require.NotNil(t, config, "Config should not be nil when error is nil") assert.Equal(t, tc.requestTimeout, config.Timeout, "REST config should have timeout matching RequestTimeout field") } }) } } // TestConfig_ClientGenerator_RESTConfig_Integration verifies Config → ClientGenerator → RESTConfig flow func TestConfig_ClientGenerator_RESTConfig_Integration(t *testing.T) { t.Run("normal timeout is propagated", func(t *testing.T) { cfg := &Config{ KubeConfig: "", APIServerURL: "", RequestTimeout: 45 * time.Second, UpdateEvents: false, } gen := cfg.ClientGenerator() // Verify ClientGenerator has correct timeout assert.Equal(t, 45*time.Second, gen.RequestTimeout, "ClientGenerator should have the configured RequestTimeout") config, err := gen.RESTConfig() // Even if config creation fails, the timeout setting should be correct assert.Equal(t, 45*time.Second, gen.RequestTimeout, "RequestTimeout should remain 45s after RESTConfig() call") if err == nil { require.NotNil(t, config, "Config should not be nil when error is nil") assert.Equal(t, 45*time.Second, config.Timeout, "RESTConfig should propagate the timeout") } }) t.Run("UpdateEvents sets timeout to zero", func(t *testing.T) { cfg := &Config{ KubeConfig: "", APIServerURL: "", RequestTimeout: 45 * time.Second, UpdateEvents: true, // Should override to 0 } gen := cfg.ClientGenerator() // When UpdateEvents=true, ClientGenerator sets timeout to 0 (for long-running watches) assert.Equal(t, time.Duration(0), gen.RequestTimeout, "ClientGenerator should have zero timeout when UpdateEvents=true") config, err := gen.RESTConfig() // Verify the timeout is 0, regardless of whether config was created assert.Equal(t, time.Duration(0), gen.RequestTimeout, "RequestTimeout should remain 0 after RESTConfig() call") if err == nil { require.NotNil(t, config, "Config should not be nil when error is nil") assert.Equal(t, time.Duration(0), config.Timeout, "RESTConfig should have zero timeout for watch operations") } }) } // TestSingletonClientGenerator_RESTConfig_SharedAcrossClients verifies singleton is shared func TestSingletonClientGenerator_RESTConfig_SharedAcrossClients(t *testing.T) { gen := &SingletonClientGenerator{ KubeConfig: "/nonexistent/path/to/kubeconfig", APIServerURL: "", RequestTimeout: 30 * time.Second, } // Get REST config multiple times restConfig1, err1 := gen.RESTConfig() restConfig2, err2 := gen.RESTConfig() restConfig3, err3 := gen.RESTConfig() // Verify singleton behavior - all should return same instance assert.Same(t, restConfig1, restConfig2, "RESTConfig should return same instance on second call") assert.Same(t, restConfig1, restConfig3, "RESTConfig should return same instance on third call") // Verify the internal field matches assert.Same(t, restConfig1, gen.restConfig, "Internal restConfig field should match returned value") // Verify first call had error (no valid kubeconfig) assert.Error(t, err1, "First call should return error when kubeconfig is invalid") // Due to sync.Once bug, subsequent calls won't return the error // This is documented in the TODO comment on SingletonClientGenerator require.NoError(t, err2, "Second call does not return error due to sync.Once bug") require.NoError(t, err3, "Third call does not return error due to sync.Once bug") } ================================================ FILE: source/traefik_proxy.go ================================================ /* Copyright 2022 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 source import ( "context" "errors" "fmt" "regexp" "strings" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/informers" ) var ( ingressRouteGVR = schema.GroupVersionResource{ Group: "traefik.io", Version: "v1alpha1", Resource: "ingressroutes", } ingressRouteTCPGVR = schema.GroupVersionResource{ Group: "traefik.io", Version: "v1alpha1", Resource: "ingressroutetcps", } ingressRouteUDPGVR = schema.GroupVersionResource{ Group: "traefik.io", Version: "v1alpha1", Resource: "ingressrouteudps", } oldIngressRouteGVR = schema.GroupVersionResource{ Group: "traefik.containo.us", Version: "v1alpha1", Resource: "ingressroutes", } oldIngressRouteTCPGVR = schema.GroupVersionResource{ Group: "traefik.containo.us", Version: "v1alpha1", Resource: "ingressroutetcps", } oldIngressRouteUDPGVR = schema.GroupVersionResource{ Group: "traefik.containo.us", Version: "v1alpha1", Resource: "ingressrouteudps", } ) var ( traefikHostExtractor = regexp.MustCompile(`(?:HostSNI|HostHeader|Host)\s*\(\s*(\x60.*?\x60)\s*\)`) traefikValueProcessor = regexp.MustCompile(`\x60([^,\x60]+)\x60`) ) // +externaldns:source:name=traefik-proxy // +externaldns:source:category=Ingress Controllers // +externaldns:source:description=Creates DNS entries from Traefik IngressRoute, IngressRouteTCP, and IngressRouteUDP resources // +externaldns:source:resources=IngressRoute.traefik.io,IngressRouteTCP.traefik.io,IngressRouteUDP.traefik.io // +externaldns:source:filters=annotation // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=false // +externaldns:source:provider-specific=true type traefikSource struct { dynamicKubeClient dynamic.Interface kubeClient kubernetes.Interface annotationFilter string namespace string ignoreHostnameAnnotation bool ingressRouteInformer kubeinformers.GenericInformer ingressRouteTcpInformer kubeinformers.GenericInformer ingressRouteUdpInformer kubeinformers.GenericInformer oldIngressRouteInformer kubeinformers.GenericInformer oldIngressRouteTcpInformer kubeinformers.GenericInformer oldIngressRouteUdpInformer kubeinformers.GenericInformer unstructuredConverter *unstructuredConverter } func NewTraefikSource( ctx context.Context, dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, cfg *Config, ) (Source, error) { // Use shared informer to listen for add/update/delete of Host in the specified namespace. // Set resync period to 0, to prevent processing when nothing has changed. informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, cfg.Namespace, nil) var ingressRouteInformer, ingressRouteTcpInformer, ingressRouteUdpInformer kubeinformers.GenericInformer var oldIngressRouteInformer, oldIngressRouteTcpInformer, oldIngressRouteUdpInformer kubeinformers.GenericInformer // Add default resource event handlers to properly initialize informers. if !cfg.TraefikDisableNew { ingressRouteInformer = informerFactory.ForResource(ingressRouteGVR) ingressRouteTcpInformer = informerFactory.ForResource(ingressRouteTCPGVR) ingressRouteUdpInformer = informerFactory.ForResource(ingressRouteUDPGVR) _, _ = ingressRouteInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = ingressRouteTcpInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = ingressRouteUdpInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) } if cfg.TraefikEnableLegacy { oldIngressRouteInformer = informerFactory.ForResource(oldIngressRouteGVR) oldIngressRouteTcpInformer = informerFactory.ForResource(oldIngressRouteTCPGVR) oldIngressRouteUdpInformer = informerFactory.ForResource(oldIngressRouteUDPGVR) _, _ = oldIngressRouteInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = oldIngressRouteTcpInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) _, _ = oldIngressRouteUdpInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) } informerFactory.Start(ctx.Done()) // wait for the local cache to be populated. if err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil { return nil, err } uc, err := newTraefikUnstructuredConverter() if err != nil { return nil, fmt.Errorf("failed to setup Unstructured Converter: %w", err) } return &traefikSource{ annotationFilter: cfg.AnnotationFilter, ignoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, dynamicKubeClient: dynamicKubeClient, ingressRouteInformer: ingressRouteInformer, ingressRouteTcpInformer: ingressRouteTcpInformer, ingressRouteUdpInformer: ingressRouteUdpInformer, oldIngressRouteInformer: oldIngressRouteInformer, oldIngressRouteTcpInformer: oldIngressRouteTcpInformer, oldIngressRouteUdpInformer: oldIngressRouteUdpInformer, kubeClient: kubeClient, namespace: cfg.Namespace, unstructuredConverter: uc, }, nil } func (ts *traefikSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint if ts.ingressRouteInformer != nil { ingressRouteEndpoints, err := ts.ingressRouteEndpoints() if err != nil { return nil, err } endpoints = append(endpoints, ingressRouteEndpoints...) } if ts.oldIngressRouteInformer != nil { oldIngressRouteEndpoints, err := ts.oldIngressRouteEndpoints() if err != nil { return nil, err } endpoints = append(endpoints, oldIngressRouteEndpoints...) } if ts.ingressRouteTcpInformer != nil { ingressRouteTcpEndpoints, err := ts.ingressRouteTCPEndpoints() if err != nil { return nil, err } endpoints = append(endpoints, ingressRouteTcpEndpoints...) } if ts.oldIngressRouteTcpInformer != nil { oldIngressRouteTcpEndpoints, err := ts.oldIngressRouteTCPEndpoints() if err != nil { return nil, err } endpoints = append(endpoints, oldIngressRouteTcpEndpoints...) } if ts.ingressRouteUdpInformer != nil { ingressRouteUdpEndpoints, err := ts.ingressRouteUDPEndpoints() if err != nil { return nil, err } endpoints = append(endpoints, ingressRouteUdpEndpoints...) } if ts.oldIngressRouteUdpInformer != nil { oldIngressRouteUdpEndpoints, err := ts.oldIngressRouteUDPEndpoints() if err != nil { return nil, err } endpoints = append(endpoints, oldIngressRouteUdpEndpoints...) } return MergeEndpoints(endpoints), nil } // ingressRouteEndpoints extracts endpoints from all IngressRoute objects func (ts *traefikSource) ingressRouteEndpoints() ([]*endpoint.Endpoint, error) { return extractEndpoints( ts.ingressRouteInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRoute, error) { typed := &IngressRoute{} return typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil) }, ts.annotationFilter, func(r *IngressRoute, targets endpoint.Targets) []*endpoint.Endpoint { return ts.endpointsFromIngressRoute(r, targets) }, ) } // ingressRouteTCPEndpoints extracts endpoints from all IngressRouteTCP objects func (ts *traefikSource) ingressRouteTCPEndpoints() ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint irs, err := ts.ingressRouteTcpInformer.Lister().ByNamespace(ts.namespace).List(labels.Everything()) if err != nil { return nil, err } var ingressRouteTCPs []*IngressRouteTCP for _, ingressRouteTCPObj := range irs { unstructuredHost, ok := ingressRouteTCPObj.(*unstructured.Unstructured) if !ok { return nil, errors.New("could not convert IngressRouteTCP object to unstructured") } ingressRouteTCP := &IngressRouteTCP{} err := ts.unstructuredConverter.scheme.Convert(unstructuredHost, ingressRouteTCP, nil) if err != nil { return nil, err } ingressRouteTCPs = append(ingressRouteTCPs, ingressRouteTCP) } ingressRouteTCPs, err = annotations.Filter(ingressRouteTCPs, ts.annotationFilter) if err != nil { return nil, fmt.Errorf("failed to filter IngressRouteTCP: %w", err) } for _, ingressRouteTCP := range ingressRouteTCPs { var targets endpoint.Targets targets = append(targets, annotations.TargetsFromTargetAnnotation(ingressRouteTCP.Annotations)...) fullname := fmt.Sprintf("%s/%s", ingressRouteTCP.Namespace, ingressRouteTCP.Name) ingressEndpoints := ts.endpointsFromIngressRouteTCP(ingressRouteTCP, targets) if endpoint.HasNoEmptyEndpoints(ingressEndpoints, types.TraefikProxy, ingressRouteTCP) { continue } log.Debugf("Endpoints generated from IngressRouteTCP: %s: %v", fullname, ingressEndpoints) endpoints = append(endpoints, ingressEndpoints...) } return endpoints, nil } // ingressRouteUDPEndpoints extracts endpoints from all IngressRouteUDP objects func (ts *traefikSource) ingressRouteUDPEndpoints() ([]*endpoint.Endpoint, error) { return extractEndpoints( ts.ingressRouteUdpInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRouteUDP, error) { typed := &IngressRouteUDP{} return typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil) }, ts.annotationFilter, ts.endpointsFromIngressRouteUDP, ) } // oldIngressRouteEndpoints extracts endpoints from all IngressRoute objects func (ts *traefikSource) oldIngressRouteEndpoints() ([]*endpoint.Endpoint, error) { return extractEndpoints( ts.oldIngressRouteInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRoute, error) { typed := &IngressRoute{} return typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil) }, ts.annotationFilter, func(r *IngressRoute, targets endpoint.Targets) []*endpoint.Endpoint { return ts.endpointsFromIngressRoute(r, targets) }, ) } // oldIngressRouteTCPEndpoints extracts endpoints from all IngressRouteTCP objects func (ts *traefikSource) oldIngressRouteTCPEndpoints() ([]*endpoint.Endpoint, error) { return extractEndpoints( ts.oldIngressRouteTcpInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRouteTCP, error) { typed := &IngressRouteTCP{} return typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil) }, ts.annotationFilter, ts.endpointsFromIngressRouteTCP, ) } // oldIngressRouteUDPEndpoints extracts endpoints from all IngressRouteUDP objects func (ts *traefikSource) oldIngressRouteUDPEndpoints() ([]*endpoint.Endpoint, error) { return extractEndpoints( ts.oldIngressRouteUdpInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRouteUDP, error) { typed := &IngressRouteUDP{} return typed, ts.unstructuredConverter.scheme.Convert(u, typed, nil) }, ts.annotationFilter, ts.endpointsFromIngressRouteUDP, ) } // endpointsFromIngressRoute extracts the endpoints from a IngressRoute object func (ts *traefikSource) endpointsFromIngressRoute(ingressRoute *IngressRoute, targets endpoint.Targets) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint resource := fmt.Sprintf("ingressroute/%s/%s", ingressRoute.Namespace, ingressRoute.Name) ttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations) if !ts.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } for _, route := range ingressRoute.Spec.Routes { for _, hostEntry := range traefikHostExtractor.FindAllString(route.Match, -1) { for _, host := range traefikValueProcessor.FindAllString(hostEntry, -1) { host = strings.Trim(host, "`") // Checking for host = * is required, as Host(`*`) can be set if host != "*" && host != "" { endpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } } } return endpoints } // endpointsFromIngressRouteTCP extracts the endpoints from a IngressRouteTCP object func (ts *traefikSource) endpointsFromIngressRouteTCP(ingressRoute *IngressRouteTCP, targets endpoint.Targets) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint resource := fmt.Sprintf("ingressroutetcp/%s/%s", ingressRoute.Namespace, ingressRoute.Name) ttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations) if !ts.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } for _, route := range ingressRoute.Spec.Routes { for _, hostEntry := range traefikHostExtractor.FindAllString(route.Match, -1) { for _, host := range traefikValueProcessor.FindAllString(hostEntry, -1) { host = strings.Trim(host, "`") // Checking for host = * is required, as HostSNI(`*`) can be set // in the case of TLS passthrough if host != "*" && host != "" { endpoints = append(endpoints, endpoint.EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } } } return endpoints } // endpointsFromIngressRouteUDP extracts the endpoints from a IngressRouteUDP object func (ts *traefikSource) endpointsFromIngressRouteUDP(ingressRoute *IngressRouteUDP, targets endpoint.Targets) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint resource := fmt.Sprintf("ingressrouteudp/%s/%s", ingressRoute.Namespace, ingressRoute.Name) ttl := annotations.TTLFromAnnotations(ingressRoute.Annotations, resource) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ingressRoute.Annotations) if !ts.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpoint.EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } return endpoints } func (ts *traefikSource) AddEventHandler(_ context.Context, handler func()) { // Right now there is no way to remove event handler from informer, see: // https://github.com/kubernetes/kubernetes/issues/79610 log.Debug("Adding event handler for IngressRoute") if ts.ingressRouteInformer != nil { _, _ = ts.ingressRouteInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } if ts.oldIngressRouteInformer != nil { _, _ = ts.oldIngressRouteInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } log.Debug("Adding event handler for IngressRouteTCP") if ts.ingressRouteTcpInformer != nil { _, _ = ts.ingressRouteTcpInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } if ts.oldIngressRouteTcpInformer != nil { _, _ = ts.oldIngressRouteTcpInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } log.Debug("Adding event handler for IngressRouteUDP") if ts.ingressRouteUdpInformer != nil { _, _ = ts.ingressRouteUdpInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } if ts.oldIngressRouteUdpInformer != nil { _, _ = ts.oldIngressRouteUdpInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } } // newTraefikUnstructuredConverter returns a new unstructuredConverter initialized func newTraefikUnstructuredConverter() (*unstructuredConverter, error) { uc := &unstructuredConverter{ scheme: runtime.NewScheme(), } // Add the core types we need uc.scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) uc.scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) uc.scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) uc.scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) uc.scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) uc.scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) if err := scheme.AddToScheme(uc.scheme); err != nil { return nil, err } return uc, nil } // Basic redefinition of Traefik 2's CRD: https://github.com/traefik/traefik/tree/v2.8.7/pkg/provider/kubernetes/crd/traefik/v1alpha1 // traefikIngressRouteSpec defines the desired state of IngressRoute. type traefikIngressRouteSpec struct { // Routes defines the list of routes. Routes []traefikRoute `json:"routes"` } // traefikRoute holds the HTTP route configuration. type traefikRoute struct { // Match defines the router's rule. // More info: https://doc.traefik.io/traefik/v2.9/routing/routers/#rule Match string `json:"match"` } // IngressRoute is the CRD implementation of a Traefik HTTP Router. type IngressRoute struct { metav1.TypeMeta `json:",inline"` // Standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata metav1.ObjectMeta `json:"metadata"` Spec traefikIngressRouteSpec `json:"spec"` } // IngressRouteList is a collection of IngressRoute. type IngressRouteList struct { metav1.TypeMeta `json:",inline"` // Standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata metav1.ListMeta `json:"metadata"` // Items is the list of IngressRoute. Items []IngressRoute `json:"items"` } // traefikIngressRouteTCPSpec defines the desired state of IngressRouteTCP. type traefikIngressRouteTCPSpec struct { Routes []traefikRouteTCP `json:"routes"` } // traefikRouteTCP holds the TCP route configuration. type traefikRouteTCP struct { // Match defines the router's rule. // More info: https://doc.traefik.io/traefik/v2.9/routing/routers/#rule_1 Match string `json:"match"` } // IngressRouteTCP is the CRD implementation of a Traefik TCP Router. type IngressRouteTCP struct { metav1.TypeMeta `json:",inline"` // Standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata metav1.ObjectMeta `json:"metadata"` Spec traefikIngressRouteTCPSpec `json:"spec"` } // IngressRouteTCPList is a collection of IngressRouteTCP. type IngressRouteTCPList struct { metav1.TypeMeta `json:",inline"` // Standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata metav1.ListMeta `json:"metadata"` // Items is the list of IngressRouteTCP. Items []IngressRouteTCP `json:"items"` } // IngressRouteUDP is a CRD implementation of a Traefik UDP Router. type IngressRouteUDP struct { metav1.TypeMeta `json:",inline"` // Standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata metav1.ObjectMeta `json:"metadata"` } // IngressRouteUDPList is a collection of IngressRouteUDP. type IngressRouteUDPList struct { metav1.TypeMeta `json:",inline"` // Standard object's metadata. // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata metav1.ListMeta `json:"metadata"` // Items is the list of IngressRouteUDP. Items []IngressRouteUDP `json:"items"` } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IngressRoute) DeepCopyInto(out *IngressRoute) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRoute. func (in *IngressRoute) DeepCopy() *IngressRoute { if in == nil { return nil } out := new(IngressRoute) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. func (in *IngressRoute) 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 *IngressRouteList) DeepCopyInto(out *IngressRouteList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items *out = make([]IngressRoute, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteList. func (in *IngressRouteList) DeepCopy() *IngressRouteList { if in == nil { return nil } out := new(IngressRouteList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. func (in *IngressRouteList) 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 *traefikIngressRouteSpec) DeepCopyInto(out *traefikIngressRouteSpec) { *out = *in if in.Routes != nil { in, out := &in.Routes, &out.Routes *out = make([]traefikRoute, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteSpec. func (in *traefikIngressRouteSpec) DeepCopy() *traefikIngressRouteSpec { if in == nil { return nil } out := new(traefikIngressRouteSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *traefikRoute) DeepCopyInto(out *traefikRoute) { *out = *in } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Route. func (in *traefikRoute) DeepCopy() *traefikRoute { if in == nil { return nil } out := new(traefikRoute) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IngressRouteTCP) DeepCopyInto(out *IngressRouteTCP) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteTCP. func (in *IngressRouteTCP) DeepCopy() *IngressRouteTCP { if in == nil { return nil } out := new(IngressRouteTCP) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. func (in *IngressRouteTCP) 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 *IngressRouteTCPList) DeepCopyInto(out *IngressRouteTCPList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items *out = make([]IngressRouteTCP, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteTCPList. func (in *IngressRouteTCPList) DeepCopy() *IngressRouteTCPList { if in == nil { return nil } out := new(IngressRouteTCPList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. func (in *IngressRouteTCPList) 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 *traefikIngressRouteTCPSpec) DeepCopyInto(out *traefikIngressRouteTCPSpec) { *out = *in if in.Routes != nil { in, out := &in.Routes, &out.Routes *out = make([]traefikRouteTCP, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteTCPSpec. func (in *traefikIngressRouteTCPSpec) DeepCopy() *traefikIngressRouteTCPSpec { if in == nil { return nil } out := new(traefikIngressRouteTCPSpec) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *traefikRouteTCP) DeepCopyInto(out *traefikRouteTCP) { *out = *in } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteTCP. func (in *traefikRouteTCP) DeepCopy() *traefikRouteTCP { if in == nil { return nil } out := new(traefikRouteTCP) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IngressRouteUDP) DeepCopyInto(out *IngressRouteUDP) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteUDP. func (in *IngressRouteUDP) DeepCopy() *IngressRouteUDP { if in == nil { return nil } out := new(IngressRouteUDP) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. func (in *IngressRouteUDP) 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 *IngressRouteUDPList) DeepCopyInto(out *IngressRouteUDPList) { *out = *in out.TypeMeta = in.TypeMeta in.ListMeta.DeepCopyInto(&out.ListMeta) if in.Items != nil { in, out := &in.Items, &out.Items *out = make([]IngressRouteUDP, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressRouteUDPList. func (in *IngressRouteUDPList) DeepCopy() *IngressRouteUDPList { if in == nil { return nil } out := new(IngressRouteUDPList) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. func (in *IngressRouteUDPList) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } return nil } // GetAnnotations returns the annotations of the IngressRoute. func (in *IngressRoute) GetAnnotations() map[string]string { return in.Annotations } // GetAnnotations returns the annotations of the IngressRouteTCP. func (in *IngressRouteTCP) GetAnnotations() map[string]string { return in.Annotations } // GetAnnotations returns the annotations of the IngressRouteUDP. func (in *IngressRouteUDP) GetAnnotations() map[string]string { return in.Annotations } // extractEndpoints is a generic function that extracts endpoints from Kubernetes resources. // It performs the following steps: // 1. Lists all objects in the specified namespace using the provided informer. // 2. Converts the unstructured objects to the desired type using the convertFunc. // 3. Filters the converted objects based on the annotation filter. // 4. Generates endpoints for each filtered object using the generateEndpoints function. // Returns a list of generated endpoints or an error if any step fails. func extractEndpoints[T annotations.AnnotatedObject]( informer cache.GenericLister, namespace string, convertFunc func(*unstructured.Unstructured) (T, error), annotationFilter string, generateEndpoints func(T, endpoint.Targets) []*endpoint.Endpoint, ) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint objs, err := informer.ByNamespace(namespace).List(labels.Everything()) if err != nil { return nil, err } var typedObjs []T for _, obj := range objs { unstructuredObj, ok := obj.(*unstructured.Unstructured) if !ok { return nil, errors.New("failed to cast to unstructured.Unstructured") } typed, err := convertFunc(unstructuredObj) if err != nil { return nil, err } typedObjs = append(typedObjs, typed) } typedObjs, err = annotations.Filter(typedObjs, annotationFilter) if err != nil { return nil, err } for _, item := range typedObjs { targets := annotations.TargetsFromTargetAnnotation(item.GetAnnotations()) name := getObjectFullName(item) ingressEndpoints := generateEndpoints(item, targets) if len(ingressEndpoints) == 0 { log.Debugf("No endpoints could be generated from Host %s", name) continue } log.Debugf("Endpoints generated from %s: %v", name, ingressEndpoints) endpoints = append(endpoints, ingressEndpoints...) } return endpoints, nil } func getObjectFullName(obj any) string { switch o := obj.(type) { case *IngressRouteUDP: return fmt.Sprintf("%s/%s", o.Namespace, o.Name) case *IngressRoute: return fmt.Sprintf("%s/%s", o.Namespace, o.Name) case *IngressRouteTCP: return fmt.Sprintf("%s/%s", o.Namespace, o.Name) default: return "" } } ================================================ FILE: source/traefik_proxy_test.go ================================================ /* Copyright 2022 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 source import ( "encoding/json" "fmt" "testing" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/cache" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" fakeDynamic "k8s.io/client-go/dynamic/fake" fakeKube "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) // This is a compile-time validation that traefikSource is a Source. var _ Source = &traefikSource{} const defaultTraefikNamespace = "traefik" func TestTraefikProxyIngressRouteEndpoints(t *testing.T) { t.Parallel() for _, ti := range []struct { title string ingressRoute IngressRoute ignoreHostnameAnnotation bool expected []*endpoint.Endpoint }{ { title: "IngressRoute with hostname annotation", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute with host rule", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-host-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`b.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "b.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute with hostheader rule", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-hostheader-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "HostHeader(`c.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "c.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-hostheader-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute with multiple host rules", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-multi-host-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`d.example.com`) || Host(`e.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "d.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "e.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute with multiple host rules and annotation", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-multi-host-annotations-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "f.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`g.example.com`, `h.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "f.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "g.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute ignoring annotation", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-multi-host-annotations-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "f.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`g.example.com`, `h.example.com`)", }, }, }, }, ignoreHostnameAnnotation: true, expected: []*endpoint.Endpoint{ { DNSName: "g.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute omit wildcard", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-omit-wildcard-host", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`*`)", }, }, }, }, expected: nil, }, { title: "IngressRoute with provider-specific annotation", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-provider-specific", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ annotations.HostnameKey: "a.example.com", annotations.TargetKey: "target.domain.tld", "kubernetes.io/ingress.class": "traefik", annotations.AWSPrefix + "weight": "10", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-provider-specific", }, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) ir := unstructured.Unstructured{} ingressRouteAsJSON, err := json.Marshal(ti.ingressRoute) assert.NoError(t, err) assert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON)) // Create proxy resources _, err = fakeDynamicClient.Resource(ingressRouteGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultTraefikNamespace, AnnotationFilter: "kubernetes.io/ingress.class=traefik", IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, }) assert.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(ingressRouteGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } func TestTraefikProxyIngressRouteTCPEndpoints(t *testing.T) { t.Parallel() for _, ti := range []struct { title string ingressRouteTCP IngressRouteTCP ignoreHostnameAnnotation bool expected []*endpoint.Endpoint }{ { title: "IngressRouteTCP with hostname annotation", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP with host sni rule", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-hostsni-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`b.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "b.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-hostsni-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP with multiple host sni rules", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-multi-host-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`d.example.com`) || HostSNI(`e.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "d.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "e.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP with multiple host sni rules and annotation", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-multi-host-annotations-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "f.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`g.example.com`, `h.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "f.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "g.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP ignoring annotation", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-multi-host-annotations-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "f.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`g.example.com`, `h.example.com`)", }, }, }, }, ignoreHostnameAnnotation: true, expected: []*endpoint.Endpoint{ { DNSName: "g.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP omit wildcard host sni", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-omit-wildcard-host", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`*`)", }, }, }, }, expected: nil, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) ir := unstructured.Unstructured{} ingressRouteAsJSON, err := json.Marshal(ti.ingressRouteTCP) require.NoError(t, err) require.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON)) // Create proxy resources _, err = fakeDynamicClient.Resource(ingressRouteTCPGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{}) require.NoError(t, err) source, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultTraefikNamespace, AnnotationFilter: "kubernetes.io/ingress.class=traefik", IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, }) require.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(ingressRouteTCPGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } func TestTraefikProxyIngressRouteUDPEndpoints(t *testing.T) { t.Parallel() for _, ti := range []struct { title string ingressRouteUDP IngressRouteUDP ignoreHostnameAnnotation bool expected []*endpoint.Endpoint }{ { title: "IngressRouteTCP with hostname annotation", ingressRouteUDP: IngressRouteUDP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteUDPGVR.GroupVersion().String(), Kind: "IngressRouteUDP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressrouteudp-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressrouteudp/traefik/ingressrouteudp-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP with multiple hostname annotation", ingressRouteUDP: IngressRouteUDP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteUDPGVR.GroupVersion().String(), Kind: "IngressRouteUDP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressrouteudp-multi-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com, b.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressrouteudp/traefik/ingressrouteudp-multi-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "b.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressrouteudp/traefik/ingressrouteudp-multi-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP ignoring hostname annotation", ingressRouteUDP: IngressRouteUDP{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteUDPGVR.GroupVersion().String(), Kind: "IngressRouteUDP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressrouteudp-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, ignoreHostnameAnnotation: true, expected: nil, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) ir := unstructured.Unstructured{} ingressRouteAsJSON, err := json.Marshal(ti.ingressRouteUDP) assert.NoError(t, err) assert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON)) // Create proxy resources _, err = fakeDynamicClient.Resource(ingressRouteUDPGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultTraefikNamespace, AnnotationFilter: "kubernetes.io/ingress.class=traefik", IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, }) assert.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(ingressRouteUDPGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } func TestTraefikProxyOldIngressRouteEndpoints(t *testing.T) { t.Parallel() for _, ti := range []struct { title string ingressRoute IngressRoute ignoreHostnameAnnotation bool expected []*endpoint.Endpoint }{ { title: "IngressRoute with hostname annotation", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute with host rule", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-host-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`b.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "b.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute with hostheader rule", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-hostheader-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "HostHeader(`c.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "c.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-hostheader-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute with multiple host rules", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-multi-host-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`d.example.com`) || Host(`e.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "d.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "e.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute with multiple host rules and annotation", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-multi-host-annotations-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "f.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`g.example.com`, `h.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "f.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "g.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute ignoring annotation", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-multi-host-annotations-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "f.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`g.example.com`, `h.example.com`)", }, }, }, }, ignoreHostnameAnnotation: true, expected: []*endpoint.Endpoint{ { DNSName: "g.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute omit wildcard", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-omit-wildcard-host", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteSpec{ Routes: []traefikRoute{ { Match: "Host(`*`)", }, }, }, }, expected: nil, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) ir := unstructured.Unstructured{} ingressRouteAsJSON, err := json.Marshal(ti.ingressRoute) assert.NoError(t, err) assert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON)) // Create proxy resources _, err = fakeDynamicClient.Resource(oldIngressRouteGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultTraefikNamespace, AnnotationFilter: "kubernetes.io/ingress.class=traefik", IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, TraefikEnableLegacy: true, }) assert.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(oldIngressRouteGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } func TestTraefikProxyOldIngressRouteTCPEndpoints(t *testing.T) { t.Parallel() for _, ti := range []struct { title string ingressRouteTCP IngressRouteTCP ignoreHostnameAnnotation bool expected []*endpoint.Endpoint }{ { title: "IngressRouteTCP with hostname annotation", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP with host sni rule", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-hostsni-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`b.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "b.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-hostsni-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP with multiple host sni rules", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-multi-host-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`d.example.com`) || HostSNI(`e.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "d.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "e.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP with multiple host sni rules and annotation", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-multi-host-annotations-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "f.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`g.example.com`, `h.example.com`)", }, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "f.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "g.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP ignoring annotation", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-multi-host-annotations-match", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "f.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`g.example.com`, `h.example.com`)", }, }, }, }, ignoreHostnameAnnotation: true, expected: []*endpoint.Endpoint{ { DNSName: "g.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "h.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroutetcp/traefik/ingressroutetcp-multi-host-annotations-match", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP omit wildcard host sni", ingressRouteTCP: IngressRouteTCP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteTCPGVR.GroupVersion().String(), Kind: "IngressRouteTCP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroutetcp-omit-wildcard-host", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, Spec: traefikIngressRouteTCPSpec{ Routes: []traefikRouteTCP{ { Match: "HostSNI(`*`)", }, }, }, }, expected: nil, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) ir := unstructured.Unstructured{} ingressRouteAsJSON, err := json.Marshal(ti.ingressRouteTCP) assert.NoError(t, err) assert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON)) // Create proxy resources _, err = fakeDynamicClient.Resource(oldIngressRouteTCPGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultTraefikNamespace, AnnotationFilter: "kubernetes.io/ingress.class=traefik", IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, TraefikEnableLegacy: true, }) assert.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(oldIngressRouteTCPGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } func TestTraefikProxyOldIngressRouteUDPEndpoints(t *testing.T) { t.Parallel() for _, ti := range []struct { title string ingressRouteUDP IngressRouteUDP ignoreHostnameAnnotation bool expected []*endpoint.Endpoint }{ { title: "IngressRouteTCP with hostname annotation", ingressRouteUDP: IngressRouteUDP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteUDPGVR.GroupVersion().String(), Kind: "IngressRouteUDP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressrouteudp-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressrouteudp/traefik/ingressrouteudp-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP with multiple hostname annotation", ingressRouteUDP: IngressRouteUDP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteUDPGVR.GroupVersion().String(), Kind: "IngressRouteUDP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressrouteudp-multi-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com, b.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressrouteudp/traefik/ingressrouteudp-multi-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, { DNSName: "b.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressrouteudp/traefik/ingressrouteudp-multi-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRouteTCP ignoring hostname annotation", ingressRouteUDP: IngressRouteUDP{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteUDPGVR.GroupVersion().String(), Kind: "IngressRouteUDP", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressrouteudp-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, ignoreHostnameAnnotation: true, expected: nil, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) ir := unstructured.Unstructured{} ingressRouteAsJSON, err := json.Marshal(ti.ingressRouteUDP) assert.NoError(t, err) assert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON)) // Create proxy resources _, err = fakeDynamicClient.Resource(oldIngressRouteUDPGVR).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultTraefikNamespace, AnnotationFilter: "kubernetes.io/ingress.class=traefik", IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, TraefikEnableLegacy: true, }) assert.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(oldIngressRouteUDPGVR).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } func TestTraefikAPIGroupFlags(t *testing.T) { t.Parallel() for _, ti := range []struct { title string ingressRoute IngressRoute gvr schema.GroupVersionResource ignoreHostnameAnnotation bool enableLegacy bool disableNew bool expected []*endpoint.Endpoint }{ { title: "IngressRoute.traefik.containo.us with the legacy API group enabled", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, gvr: oldIngressRouteGVR, enableLegacy: true, disableNew: false, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute.traefik.containo.us with the legacy API group disabled", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: oldIngressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, gvr: oldIngressRouteGVR, enableLegacy: false, disableNew: false, }, { title: "IngressRoute.traefik.io with the new API group enabled", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, gvr: ingressRouteGVR, enableLegacy: true, disableNew: false, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", Targets: []string{"target.domain.tld"}, RecordType: endpoint.RecordTypeCNAME, RecordTTL: 0, Labels: endpoint.Labels{ "resource": "ingressroute/traefik/ingressroute-annotation", }, ProviderSpecific: endpoint.ProviderSpecific{}, }, }, }, { title: "IngressRoute.traefik.io with the new API group disabled", ingressRoute: IngressRoute{ TypeMeta: metav1.TypeMeta{ APIVersion: ingressRouteGVR.GroupVersion().String(), Kind: "IngressRoute", }, ObjectMeta: metav1.ObjectMeta{ Name: "ingressroute-annotation", Namespace: defaultTraefikNamespace, Annotations: map[string]string{ "external-dns.alpha.kubernetes.io/hostname": "a.example.com", "external-dns.alpha.kubernetes.io/target": "target.domain.tld", "kubernetes.io/ingress.class": "traefik", }, }, }, gvr: ingressRouteGVR, enableLegacy: true, disableNew: true, }, } { t.Run(ti.title, func(t *testing.T) { t.Parallel() fakeKubernetesClient := fakeKube.NewSimpleClientset() scheme := runtime.NewScheme() scheme.AddKnownTypes(ingressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(ingressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(ingressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) scheme.AddKnownTypes(oldIngressRouteGVR.GroupVersion(), &IngressRoute{}, &IngressRouteList{}) scheme.AddKnownTypes(oldIngressRouteTCPGVR.GroupVersion(), &IngressRouteTCP{}, &IngressRouteTCPList{}) scheme.AddKnownTypes(oldIngressRouteUDPGVR.GroupVersion(), &IngressRouteUDP{}, &IngressRouteUDPList{}) fakeDynamicClient := fakeDynamic.NewSimpleDynamicClient(scheme) ir := unstructured.Unstructured{} ingressRouteAsJSON, err := json.Marshal(ti.ingressRoute) assert.NoError(t, err) assert.NoError(t, ir.UnmarshalJSON(ingressRouteAsJSON)) // Create proxy resources _, err = fakeDynamicClient.Resource(ti.gvr).Namespace(defaultTraefikNamespace).Create(t.Context(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) source, err := NewTraefikSource(t.Context(), fakeDynamicClient, fakeKubernetesClient, &Config{ Namespace: defaultTraefikNamespace, AnnotationFilter: "kubernetes.io/ingress.class=traefik", IgnoreHostnameAnnotation: ti.ignoreHostnameAnnotation, TraefikEnableLegacy: ti.enableLegacy, TraefikDisableNew: ti.disableNew, }) assert.NoError(t, err) assert.NotNil(t, source) count := &unstructured.UnstructuredList{} for len(count.Items) < 1 { count, _ = fakeDynamicClient.Resource(ti.gvr).Namespace(defaultTraefikNamespace).List(t.Context(), metav1.ListOptions{}) } endpoints, err := source.Endpoints(t.Context()) assert.NoError(t, err) validateEndpoints(t, endpoints, ti.expected) }) } } func TestAddEventHandler_AllBranches(t *testing.T) { ctx := t.Context() handlerCalled := false handler := func() { handlerCalled = true } inf := testInformer{} fakeInformer := new(FakeInformer) fakeInformer.On("Informer").Return(&inf) cases := []struct { name string ts *traefikSource want int }{ {"all nil", &traefikSource{}, 0}, {"all set", &traefikSource{ ingressRouteInformer: fakeInformer, oldIngressRouteInformer: fakeInformer, ingressRouteTcpInformer: fakeInformer, oldIngressRouteTcpInformer: fakeInformer, ingressRouteUdpInformer: fakeInformer, oldIngressRouteUdpInformer: fakeInformer, }, 6}, {"some set", &traefikSource{ ingressRouteInformer: fakeInformer, oldIngressRouteInformer: fakeInformer, ingressRouteTcpInformer: nil, oldIngressRouteTcpInformer: fakeInformer, ingressRouteUdpInformer: nil, oldIngressRouteUdpInformer: nil, }, 3}, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { test.ts.AddEventHandler(ctx, handler) assert.Equal(t, test.want, inf.times) assert.False(t, handlerCalled) if test.want > 0 { fakeInformer.AssertExpectations(t) fakeInformer.AssertCalled(t, "Informer") } else { fakeInformer.AssertNotCalled(t, "Informer") } // reset the call count inf.times = 0 }) } } type FakeInformer struct { mock.Mock lister cache.GenericLister } func (f *FakeInformer) Informer() cache.SharedIndexInformer { args := f.Called() return args.Get(0).(cache.SharedIndexInformer) } func (f *FakeInformer) Lister() cache.GenericLister { return f.lister } type testInformer struct { cache.SharedIndexInformer times int } func (t *testInformer) AddEventHandler(_ cache.ResourceEventHandler) (cache.ResourceEventHandlerRegistration, error) { t.times += 1 return nil, fmt.Errorf("not implemented") } ================================================ FILE: source/types/types.go ================================================ /* 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 types type Type = string const ( Node Type = "node" Service Type = "service" Ingress Type = "ingress" Pod Type = "pod" GatewayHttpRoute Type = "gateway-httproute" GatewayGrpcRoute Type = "gateway-grpcroute" GatewayTlsRoute Type = "gateway-tlsroute" GatewayTcpRoute Type = "gateway-tcproute" GatewayUdpRoute Type = "gateway-udproute" IstioGateway Type = "istio-gateway" IstioVirtualService Type = "istio-virtualservice" AmbassadorHost Type = "ambassador-host" ContourHTTPProxy Type = "contour-httpproxy" GlooProxy Type = "gloo-proxy" TraefikProxy Type = "traefik-proxy" OpenShiftRoute Type = "openshift-route" Fake Type = "fake" Connector Type = "connector" CRD Type = "crd" SkipperRouteGroup Type = "skipper-routegroup" KongTCPIngress Type = "kong-tcpingress" F5VirtualServer Type = "f5-virtualserver" F5TransportServer Type = "f5-transportserver" Unstructured Type = "unstructured" ) ================================================ FILE: source/unstructured.go ================================================ /* Copyright 2026 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 source import ( "context" "fmt" "maps" "slices" "strings" "text/template" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/events" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" "sigs.k8s.io/external-dns/source/informers" "sigs.k8s.io/external-dns/source/types" ) // unstructuredSource is a Source that creates DNS records from unstructured resources. // // +externaldns:source:name=unstructured // +externaldns:source:category=Custom Resources // +externaldns:source:description=Creates DNS entries from unstructured Kubernetes resources // +externaldns:source:resources=Unstructured // +externaldns:source:filters=annotation,label // +externaldns:source:namespace=all,single // +externaldns:source:fqdn-template=true // +externaldns:source:provider-specific=false // +externaldns:source:events=false type unstructuredSource struct { combineFqdnAnnotation bool fqdnTemplate *template.Template targetTemplate *template.Template fqdnTargetTemplate *template.Template informers []kubeinformers.GenericInformer } // NewUnstructuredFQDNSource creates a new unstructuredSource. func NewUnstructuredFQDNSource( ctx context.Context, dynamicClient dynamic.Interface, kubeClient kubernetes.Interface, cfg *Config, ) (Source, error) { fqdnTmpl, err := fqdn.ParseTemplate(cfg.FQDNTemplate) if err != nil { return nil, err } targetTmpl, err := fqdn.ParseTemplate(cfg.TargetTemplate) if err != nil { return nil, err } fqdnTargetTmpl, err := fqdn.ParseTemplate(cfg.FQDNTargetTemplate) if err != nil { return nil, err } gvrs, err := discoverResources(kubeClient, cfg.UnstructuredResources) if err != nil { return nil, err } // Create a single informer factory for all resources informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory( dynamicClient, 0, cfg.Namespace, nil, ) // Create informers for each resource resourceInformers := make([]kubeinformers.GenericInformer, 0, len(gvrs)) for _, gvr := range gvrs { informer := informerFactory.ForResource(gvr) // Add indexers for efficient lookups by namespace and labels (must be before AddEventHandler) err := informer.Informer().AddIndexers( informers.IndexerWithOptions[*unstructured.Unstructured]( informers.IndexSelectorWithAnnotationFilter(cfg.AnnotationFilter), informers.IndexSelectorWithLabelSelector(cfg.LabelFilter), ), ) if err != nil { return nil, err } _, _ = informer.Informer().AddEventHandler(informers.DefaultEventHandler()) resourceInformers = append(resourceInformers, informer) } informerFactory.Start(ctx.Done()) if err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil { return nil, err } return &unstructuredSource{ fqdnTemplate: fqdnTmpl, targetTemplate: targetTmpl, fqdnTargetTemplate: fqdnTargetTmpl, informers: resourceInformers, combineFqdnAnnotation: cfg.CombineFQDNAndAnnotation, }, nil } // Endpoints returns the list of endpoints from unstructured resources. func (us *unstructuredSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint for _, informer := range us.informers { resourceEndpoints, err := us.endpointsFromInformer(informer) if err != nil { return nil, err } endpoints = append(endpoints, resourceEndpoints...) } return endpoints, nil } // endpointsFromInformer returns endpoints for a single resource type. func (us *unstructuredSource) endpointsFromInformer(informer kubeinformers.GenericInformer) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint // Get objects that match the indexer filter (annotation and label selectors) indexKeys := informer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors) if len(indexKeys) == 0 { return nil, nil } for _, key := range indexKeys { obj, err := informers.GetByKey[*unstructured.Unstructured](informer.Informer().GetIndexer(), key) if err != nil { continue } el := newUnstructuredWrapper(obj) if annotations.IsControllerMismatch(el, types.Unstructured) { continue } hosts := annotations.HostnamesFromAnnotations(el.GetAnnotations()) addrs := annotations.TargetsFromTargetAnnotation(el.GetAnnotations()) annotationEdps := EndpointsForHostsAndTargets(hosts, addrs) fqdnTargetEdps, err := fqdn.CombineWithTemplatedEndpoints( annotationEdps, us.fqdnTargetTemplate, us.combineFqdnAnnotation, func() ([]*endpoint.Endpoint, error) { return us.endpointsFromFQDNTargetTemplate(el) }, ) if err != nil { return nil, err } edps, err := fqdn.CombineWithTemplatedEndpoints( fqdnTargetEdps, us.fqdnTemplate, us.combineFqdnAnnotation, func() ([]*endpoint.Endpoint, error) { return us.endpointsFromTemplate(el) }, ) if err != nil { return nil, err } ttl := annotations.TTLFromAnnotations(el.GetAnnotations(), fmt.Sprintf("%s/%s", strings.ToLower(el.GetKind()), el.GetName())) for _, ep := range edps { ep. WithRefObject(events.NewObjectReference(el, types.Unstructured)). WithLabel(endpoint.ResourceLabelKey, fmt.Sprintf("%s/%s/%s", strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName())). WithMinTTL(int64(ttl)) endpoints = append(endpoints, ep) } } return MergeEndpoints(endpoints), nil } // endpointsFromTemplate creates endpoints using DNS names from the FQDN template. func (us *unstructuredSource) endpointsFromTemplate(el *unstructuredWrapper) ([]*endpoint.Endpoint, error) { hostnames, err := fqdn.ExecTemplate(us.fqdnTemplate, el) if err != nil { return nil, err } if len(hostnames) == 0 { return nil, nil } var targets []string if us.targetTemplate != nil { targets, err = fqdn.ExecTemplate(us.targetTemplate, el) if err != nil { return nil, err } } return EndpointsForHostsAndTargets(hostnames, targets), nil } // endpointsFromFQDNTargetTemplate creates endpoints from a template that returns host:target pairs. // Each pair creates a single endpoint with 1:1 mapping between host and target. func (us *unstructuredSource) endpointsFromFQDNTargetTemplate(el *unstructuredWrapper) ([]*endpoint.Endpoint, error) { pairs, err := fqdn.ExecTemplate(us.fqdnTargetTemplate, el) if err != nil { return nil, err } if len(pairs) == 0 { return nil, nil } endpoints := make([]*endpoint.Endpoint, 0, len(pairs)) for _, pair := range pairs { // Split at first colon (hostnames can't contain colons, IPv6 targets can) parts := strings.SplitN(pair, ":", 2) if len(parts) != 2 { log.Debugf("Skipping invalid host:target pair %q from %s %s/%s: missing ':' separator", pair, strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName()) continue } host := strings.TrimSpace(parts[0]) target := strings.TrimSpace(parts[1]) if host == "" || target == "" { log.Debugf("Skipping incomplete host:target pair %q from %s %s/%s: field may not yet be populated", pair, strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName()) continue } endpoints = append(endpoints, endpoint.NewEndpoint(host, endpoint.SuitableType(target), target)) } return MergeEndpoints(endpoints), nil } // AddEventHandler adds an event handler that is called when resources change. func (us *unstructuredSource) AddEventHandler(_ context.Context, handler func()) { for _, informer := range us.informers { _, _ = informer.Informer().AddEventHandler(eventHandlerFunc(handler)) } } // unstructuredWrapper wraps an unstructured.Unstructured to provide both // typed-style template access ({{ .Name }}, {{ .Namespace }}) and raw map access // ({{ .Spec.field }}, {{ index .Status.interfaces 0 "ipAddress" }}). // By embedding *unstructured.Unstructured, it implements kubeObject (runtime.Object + metav1.Object). type unstructuredWrapper struct { *unstructured.Unstructured // Typed-style convenience fields (like typed Kubernetes objects) Name string Namespace string Kind string APIVersion string Labels map[string]string Annotations map[string]string // Raw map sections for custom field access Metadata map[string]any Spec map[string]any Status map[string]any } func (u *unstructuredWrapper) GetObjectMeta() metav1.Object { return u.Unstructured } // newUnstructuredWrapper creates a wrapper around an *unstructured.Unstructured, // exposing typed convenience fields for templates alongside raw map sections. func newUnstructuredWrapper(u *unstructured.Unstructured) *unstructuredWrapper { w := &unstructuredWrapper{ Unstructured: u, Name: u.GetName(), Namespace: u.GetNamespace(), Kind: u.GetKind(), APIVersion: u.GetAPIVersion(), Labels: u.GetLabels(), Annotations: u.GetAnnotations(), } // Extract common sections if metadata, ok := u.Object["metadata"].(map[string]any); ok { w.Metadata = metadata } if spec, ok := u.Object["spec"].(map[string]any); ok { w.Spec = spec } if status, ok := u.Object["status"].(map[string]any); ok { w.Status = status } return w } // discoverResources parses and validates resource identifiers against the cluster. // It uses a cached discovery client to minimize API calls. func discoverResources(kubeClient kubernetes.Interface, resources []string) ([]schema.GroupVersionResource, error) { cachedDiscovery := memory.NewMemCacheClient(kubeClient.Discovery()) gvrs := make([]schema.GroupVersionResource, 0, len(resources)) for _, r := range resources { // Handle core API resources (e.g., "configmaps.v1" -> "configmaps.v1.") if strings.Count(r, ".") == 1 { r += "." } gvr, _ := schema.ParseResourceArg(r) if gvr == nil { return nil, fmt.Errorf("invalid resource identifier %q: expected format resource.version.group (e.g., certificates.v1.cert-manager.io)", r) } if err := validateResource(cachedDiscovery, *gvr); err != nil { return nil, err } gvrs = append(gvrs, *gvr) } return gvrs, nil } // validateResource validates that a resource exists in the cluster. // It uses the Discovery API to verify the resource is available. func validateResource(discoveryClient discovery.DiscoveryInterface, gvr schema.GroupVersionResource) error { gv := gvr.GroupVersion().String() apiResourceList, err := discoveryClient.ServerResourcesForGroupVersion(gv) if err != nil { return fmt.Errorf("failed to discover resources for %q: %w", gv, err) } for i := range apiResourceList.APIResources { if apiResourceList.APIResources[i].Name == gvr.Resource { return nil } } return fmt.Errorf("resource %q not found in %q", gvr.Resource, gv) } // EndpointsForHostsAndTargets creates endpoints by grouping targets by record type // and creating an endpoint for each hostname/record-type combination. // The function returns endpoints in deterministic order (sorted by record type). func EndpointsForHostsAndTargets(hostnames, targets []string) []*endpoint.Endpoint { if len(hostnames) == 0 || len(targets) == 0 { return nil } // Deduplicate hostnames hostSet := make(map[string]struct{}, len(hostnames)) for _, h := range hostnames { hostSet[h] = struct{}{} } sortedHosts := slices.Sorted(maps.Keys(hostSet)) // Group and deduplicate targets by record type targetsByType := make(map[string]map[string]struct{}) for _, target := range targets { recordType := endpoint.SuitableType(target) if targetsByType[recordType] == nil { targetsByType[recordType] = make(map[string]struct{}) } targetsByType[recordType][target] = struct{}{} } // Resolve to sorted slices once sortedTypes := slices.Sorted(maps.Keys(targetsByType)) sortedTargets := make(map[string][]string, len(targetsByType)) for _, recordType := range sortedTypes { sortedTargets[recordType] = slices.Sorted(maps.Keys(targetsByType[recordType])) } endpoints := make([]*endpoint.Endpoint, 0, len(sortedHosts)*len(sortedTypes)) for _, hostname := range sortedHosts { for _, recordType := range sortedTypes { endpoints = append(endpoints, endpoint.NewEndpoint(hostname, recordType, sortedTargets[recordType]...)) } } return endpoints } ================================================ FILE: source/unstructured_converter.go ================================================ /* Copyright 2017 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 source import ( projectcontour "github.com/projectcontour/contour/apis/projectcontour/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" ) // UnstructuredConverter handles conversions between unstructured.Unstructured and Contour types type UnstructuredConverter struct { // scheme holds an initializer for converting Unstructured to a type scheme *runtime.Scheme } // NewUnstructuredConverter returns a new UnstructuredConverter initialized func NewUnstructuredConverter() (*UnstructuredConverter, error) { uc := &UnstructuredConverter{ scheme: runtime.NewScheme(), } // Setup converter to understand custom CRD types _ = projectcontour.AddToScheme(uc.scheme) // Add the core types we need if err := scheme.AddToScheme(uc.scheme); err != nil { return nil, err } return uc, nil } ================================================ FILE: source/unstructured_fqdn_test.go ================================================ /* Copyright 2026 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 source import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" "sigs.k8s.io/external-dns/source/fqdn" ) func TestUnstructuredFqdnTemplatingExamples(t *testing.T) { type cfg struct { resources []string fqdnTemplate string targetTemplate string fqdnTargetTemplate string labelFilter string combine bool } for _, tt := range []struct { title string cfg cfg objects []*unstructured.Unstructured expected []*endpoint.Endpoint }{ { title: "ConfigMap with comma-separated hostnames", cfg: cfg{ resources: []string{"configmaps.v1"}, fqdnTemplate: `{{index .Object.data "hostnames"}}`, targetTemplate: `{{index .Object.data "target"}}`, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ "name": "multi-dns", "namespace": "default", "annotations": map[string]any{ annotations.ControllerKey: annotations.ControllerValue, }, }, "data": map[string]any{ "hostnames": "entry1.internal.tld,entry2.example.tld", "target": "10.10.10.10", }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("entry1.internal.tld", endpoint.RecordTypeA, "10.10.10.10"). WithLabel(endpoint.ResourceLabelKey, "configmap/default/multi-dns"), endpoint.NewEndpoint("entry2.example.tld", endpoint.RecordTypeA, "10.10.10.10"). WithLabel(endpoint.ResourceLabelKey, "configmap/default/multi-dns"), }, }, { title: "with IP address", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, fqdnTemplate: `{{.Name}}.{{index .Status.interfaces 0 "name"}}.vmi.com`, targetTemplate: `{{index .Status.interfaces 0 "ipAddress"}}`, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "my-vm", "namespace": "default", }, "status": map[string]any{ "interfaces": []any{ map[string]any{ "ipAddress": "10.244.1.50", "name": "main", }, }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("my-vm.main.vmi.com", endpoint.RecordTypeA, "10.244.1.50"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/my-vm"), }, }, { title: "Crossplane RDSInstance with endpoint", cfg: cfg{ resources: []string{"rdsinstances.v1alpha1.rds.aws.crossplane.io"}, fqdnTemplate: "{{.Name}}.db.example.com", targetTemplate: "{{.Status.atProvider.endpoint.address}}", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "rds.aws.crossplane.io/v1alpha1", "kind": "RDSInstance", "metadata": map[string]any{ "name": "prod-postgres", "namespace": "default", }, "status": map[string]any{ "atProvider": map[string]any{ "endpoint": map[string]any{ "address": "prod-postgres.abc123.us-east-1.rds", }, }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("prod-postgres.db.example.com", endpoint.RecordTypeCNAME, "prod-postgres.abc123.us-east-1.rds."). WithLabel(endpoint.ResourceLabelKey, "rdsinstance/default/prod-postgres"), }, }, { title: "multiple VirtualMachineInstances", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, fqdnTemplate: "{{.Name}}.vmi.example.com", targetTemplate: `{{index .Status.interfaces 0 "ipAddress"}}`, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "vm-one", "namespace": "default", }, "status": map[string]any{ "interfaces": []any{ map[string]any{"ipAddress": "10.244.1.10"}, }, }, }, }, { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "vm-two", "namespace": "default", }, "status": map[string]any{ "interfaces": []any{ map[string]any{"ipAddress": "10.244.1.20"}, }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("vm-one.vmi.example.com", endpoint.RecordTypeA, "10.244.1.10"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/vm-one"), endpoint.NewEndpoint("vm-two.vmi.example.com", endpoint.RecordTypeA, "10.244.1.20"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/vm-two"), }, }, { title: "multiple hosts from template", cfg: cfg{ resources: []string{"proxyservices.v1beta1.proxyconfigs.acme.corp"}, fqdnTemplate: "{{.Name}}.mesh.com,{{.Name}}.internal.com", targetTemplate: "{{index .Spec.hosts 0}}", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "proxyconfigs.acme.corp/v1beta1", "kind": "ProxyService", "metadata": map[string]any{ "name": "reviews", "namespace": "default", }, "spec": map[string]any{ "hosts": []any{ "promo.svc.local", }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("reviews.internal.com", endpoint.RecordTypeCNAME, "promo.svc.local"). WithLabel(endpoint.ResourceLabelKey, "proxyservice/default/reviews"), endpoint.NewEndpoint("reviews.mesh.com", endpoint.RecordTypeCNAME, "promo.svc.local"). WithLabel(endpoint.ResourceLabelKey, "proxyservice/default/reviews"), }, }, { title: "with labels", cfg: cfg{ resources: []string{"applications.v1alpha1.argoproj.io"}, fqdnTemplate: `{{index .Labels "app.kubernetes.io/instance"}}.apps.com`, targetTemplate: "{{.Status.loadBalancer}}", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "argoproj.io/v1alpha1", "kind": "Application", "metadata": map[string]any{ "name": "guestbook", "namespace": "default", "labels": map[string]any{ "app.kubernetes.io/instance": "guestbook-prod", }, }, "status": map[string]any{ "loadBalancer": "lb.example.com", }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("guestbook-prod.apps.com", endpoint.RecordTypeCNAME, "lb.example.com"). WithLabel(endpoint.ResourceLabelKey, "application/default/guestbook"), }, }, { title: "with ttl annotation set", cfg: cfg{ resources: []string{"applications.v1alpha1.argoproj.io"}, fqdnTemplate: `{{index .Labels "app.kubernetes.io/instance"}}.apps.com`, targetTemplate: "{{.Status.loadBalancer}}", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "argoproj.io/v1alpha1", "kind": "Application", "metadata": map[string]any{ "name": "guestbook", "namespace": "ns", "labels": map[string]any{ "app.kubernetes.io/instance": "guestbook-prod", }, "annotations": map[string]any{ annotations.TtlKey: "300", }, }, "status": map[string]any{ "loadBalancer": "lb.example.com", }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("guestbook-prod.apps.com", endpoint.RecordTypeCNAME, 300, "lb.example.com"). WithLabel(endpoint.ResourceLabelKey, "application/ns/guestbook"), }, }, { title: "two different resource types - VirtualMachineInstance and RDSInstance", cfg: cfg{ resources: []string{ "virtualmachineinstances.v1.kubevirt.io", "rdsinstances.v1alpha1.rds.aws.crossplane.io", }, fqdnTemplate: "{{.Name}}.{{.Namespace}}.com", targetTemplate: ` {{if .Status.interfaces}}{{index .Status.interfaces 0 "ipAddress"}}{{else}}{{.Status.atProvider.endpoint.address}}{{end}}`, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "my-vm", "namespace": "vms", }, "status": map[string]any{ "interfaces": []any{ map[string]any{ "ipAddress": "10.244.1.100", }, }, }, }, }, { Object: map[string]any{ "apiVersion": "rds.aws.crossplane.io/v1alpha1", "kind": "RDSInstance", "metadata": map[string]any{ "name": "my-db", "namespace": "databases", }, "status": map[string]any{ "atProvider": map[string]any{ "endpoint": map[string]any{ "address": "my-db.abc123.us-west-2.rds", }, }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("my-db.databases.com", endpoint.RecordTypeCNAME, "my-db.abc123.us-west-2.rds"). WithLabel(endpoint.ResourceLabelKey, "rdsinstance/databases/my-db"), endpoint.NewEndpoint("my-vm.vms.com", endpoint.RecordTypeA, "10.244.1.100"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/vms/my-vm"), }, }, { title: "two different resource types with same template", cfg: cfg{ resources: []string{ "virtualmachineinstances.v1.kubevirt.io", "targetgroupbindings.v1beta1.elbv2.k8s.aws", }, fqdnTemplate: "{{.Name}}.{{.Kind}}.example.com", targetTemplate: "{{.Status.target}}", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "web-server", "namespace": "default", }, "status": map[string]any{ "target": "192.168.1.10", }, }, }, { Object: map[string]any{ "apiVersion": "elbv2.k8s.aws/v1beta1", "kind": "TargetGroupBinding", "metadata": map[string]any{ "name": "api-tgb", "namespace": "default", }, "status": map[string]any{ "target": "lb.api.example.com", }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("api-tgb.TargetGroupBinding.example.com", endpoint.RecordTypeCNAME, "lb.api.example.com"). WithLabel(endpoint.ResourceLabelKey, "targetgroupbinding/default/api-tgb"), endpoint.NewEndpoint("web-server.VirtualMachineInstance.example.com", endpoint.RecordTypeA, "192.168.1.10"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/web-server"), }, }, { title: "combined annotations and template", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, fqdnTemplate: "{{.Name}}.template.example.com", targetTemplate: `{{index .Status.interfaces 0 "ipAddress"}}`, combine: true, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "my-vm", "namespace": "default", "annotations": map[string]any{ annotations.HostnameKey: "my-vm.annotation.example.com", annotations.TargetKey: "192.168.1.100", }, }, "status": map[string]any{ "interfaces": []any{ map[string]any{ "ipAddress": "10.244.1.50", }, }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("my-vm.annotation.example.com", endpoint.RecordTypeA, "192.168.1.100"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/my-vm"), endpoint.NewEndpoint("my-vm.template.example.com", endpoint.RecordTypeA, "10.244.1.50"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/my-vm"), }, }, { title: "three different resource types", cfg: cfg{ resources: []string{ "virtualmachineinstances.v1.kubevirt.io", "targetgroupbinding.v1beta1.elbv2.k8s.aws", "apisixroute.v2.apisix.apache.org", }, fqdnTemplate: ` {{if eq .Kind "VirtualMachineInstance"}}{{.Name}}.vm.com{{end}}, {{if eq .Kind "TargetGroupBinding"}}{{.Name}}.tgb.com{{end}}, {{if eq .Kind "ApisixRoute"}}{{.Name}}.route.com{{end}}`, targetTemplate: ` {{if eq .Kind "VirtualMachineInstance"}}{{index .Status.interfaces 0 "ipAddress"}}{{end}}, {{if eq .Kind "TargetGroupBinding"}}{{.Status.loadBalancerHostname}}{{end}}, {{if eq .Kind "ApisixRoute"}}{{.Status.apisix.gateway}}{{end}}`, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "my-vm", "namespace": "vms", }, "status": map[string]any{ "interfaces": []any{ map[string]any{ "ipAddress": "10.0.0.1", "name": "default", }, }, }, }, }, { Object: map[string]any{ "apiVersion": "elbv2.k8s.aws/v1beta1", "kind": "TargetGroupBinding", "metadata": map[string]any{ "name": "my-tgb", "namespace": "apps", }, "status": map[string]any{ "loadBalancerHostname": "my-alb.us-east-1.elb.amazonaws.com", }, }, }, { Object: map[string]any{ "apiVersion": "apisix.apache.org/v2", "kind": "ApisixRoute", "metadata": map[string]any{ "name": "httpbin", "namespace": "ingress-apisix", }, "spec": map[string]any{ "ingressClassName": "apisix", "http": []any{ map[string]any{ "name": "httpbin", "match": map[string]any{ "paths": []any{"/ip"}, }, "backends": []any{ map[string]any{ "serviceName": "httpbin", "servicePort": int64(80), }, }, }, }, }, "status": map[string]any{ "apisix": map[string]any{ "gateway": "apisix-gateway.ingress-apisix.svc.cluster.local", }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("httpbin.route.com", endpoint.RecordTypeCNAME, "apisix-gateway.ingress-apisix.svc.cluster.local"). WithLabel(endpoint.ResourceLabelKey, "apisixroute/ingress-apisix/httpbin"), endpoint.NewEndpoint("my-tgb.tgb.com", endpoint.RecordTypeCNAME, "my-alb.us-east-1.elb.amazonaws.com"). WithLabel(endpoint.ResourceLabelKey, "targetgroupbinding/apps/my-tgb"), endpoint.NewEndpoint("my-vm.vm.com", endpoint.RecordTypeA, "10.0.0.1"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/vms/my-vm"), }, }, { title: "ACK S3 Bucket with FieldExport to ConfigMap", cfg: cfg{ resources: []string{ "buckets.v1alpha1.s3.services.k8s.aws", "fieldexports.v1alpha1.services.k8s.aws", "configmap.v1"}, fqdnTemplate: `{{if eq .Kind "ConfigMap"}}{{.Name}}.s3.example.com{{end}}`, targetTemplate: ` {{if eq .Kind "ConfigMap"}}{{$url := index .Object.data "default.export-user-data-bucket"}}{{trimSuffix (trimPrefix $url "https://") "/"}}{{end}}`, labelFilter: "app.kubernetes.io/name=example-app", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "s3.services.k8s.aws/v1alpha1", "kind": "Bucket", "metadata": map[string]any{ "name": "application-user-data", "namespace": "default", "labels": map[string]any{ "app.kubernetes.io/name": "example-app", "app.kubernetes.io/part-of": "exported-config", }, "annotations": map[string]any{ annotations.ControllerKey: annotations.ControllerValue, }, }, "spec": map[string]any{ "name": "doc-example-bucket", }, }, }, { Object: map[string]any{ "apiVersion": "services.k8s.aws/v1alpha1", "kind": "FieldExport", "metadata": map[string]any{ "name": "export-user-data-bucket", "namespace": "default", "labels": map[string]any{ "app.kubernetes.io/name": "example-app", "app.kubernetes.io/part-of": "exported-config", }, "annotations": map[string]any{ annotations.ControllerKey: annotations.ControllerValue, }, }, "spec": map[string]any{ "to": map[string]any{ "name": "application-user-data-cm", "namespace": "default", "kind": "configmap", }, "from": map[string]any{ "path": ".status.location", "resource": map[string]any{ "group": "s3.services.k8s.aws", "kind": "Bucket", "name": "application-user-data", "namespace": "default", }, }, }, }, }, { Object: map[string]any{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{ "name": "application-user-data-cm", "namespace": "default", "labels": map[string]any{ "app.kubernetes.io/name": "example-app", "app.kubernetes.io/part-of": "exported-config", }, "annotations": map[string]any{ annotations.ControllerKey: annotations.ControllerValue, }, }, "data": map[string]any{ "default.export-user-data-bucket": "https://doc-example-bucket.s3.amazonaws.com/", }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("application-user-data-cm.s3.example.com", endpoint.RecordTypeCNAME, "doc-example-bucket.s3.amazonaws.com"). WithLabel(endpoint.ResourceLabelKey, "configmap/default/application-user-data-cm"), }, }, { title: "EndpointSlice for headless service with per-pod DNS", cfg: cfg{ resources: []string{"endpointslices.v1.discovery.k8s.io"}, fqdnTargetTemplate: ` {{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}} {{range $ep := .Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$ep.targetRef.name}}.pod.com:{{.}},{{end}}{{end}}{{end}}{{end}}`, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "discovery.k8s.io/v1", "kind": "EndpointSlice", "metadata": map[string]any{ "name": "test-headless-abc12", "namespace": "default", "labels": map[string]any{ "endpointslice.kubernetes.io/managed-by": "endpointslice-controller.k8s.io", "kubernetes.io/service-name": "test-headless", "service.kubernetes.io/headless": "", }, }, "addressType": "IPv4", "endpoints": []any{ map[string]any{ "addresses": []any{"10.244.1.2", "2001:db8::1"}, "conditions": map[string]any{ "ready": true, }, "nodeName": "worker1", "targetRef": map[string]any{ "kind": "Pod", "name": "app-abc12", "namespace": "default", }, }, map[string]any{ "addresses": []any{"10.244.2.3", "10.244.2.4"}, "conditions": map[string]any{ "ready": true, }, "nodeName": "worker2", "targetRef": map[string]any{ "kind": "Pod", "name": "app-def34", "namespace": "default", }, }, }, "ports": []any{ map[string]any{ "name": "http", "port": int64(80), "protocol": "TCP", }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("app-abc12.pod.com", endpoint.RecordTypeA, "10.244.1.2"). WithLabel(endpoint.ResourceLabelKey, "endpointslice/default/test-headless-abc12"), endpoint.NewEndpoint("app-abc12.pod.com", endpoint.RecordTypeAAAA, "2001:db8::1"). WithLabel(endpoint.ResourceLabelKey, "endpointslice/default/test-headless-abc12"), endpoint.NewEndpoint("app-def34.pod.com", endpoint.RecordTypeA, "10.244.2.3", "10.244.2.4"). WithLabel(endpoint.ResourceLabelKey, "endpointslice/default/test-headless-abc12"), }, }, { title: "EndpointSlice for headless service with single FQDN per EndpointSlice", cfg: cfg{ resources: []string{"endpointslices.v1.discovery.k8s.io"}, fqdnTargetTemplate: ` {{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}} {{$svcName := index .Labels "kubernetes.io/service-name"}}{{range $ep := .Object.endpoints}} {{if $ep.conditions.ready}}{{range $ep.addresses}}{{$svcName}}.example.com:{{.}},{{end}}{{end}}{{end}}{{end}}`, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "discovery.k8s.io/v1", "kind": "EndpointSlice", "metadata": map[string]any{ "name": "test-headless-abc12", "namespace": "default", "labels": map[string]any{ "endpointslice.kubernetes.io/managed-by": "endpointslice-controller.k8s.io", "kubernetes.io/service-name": "my-headless", "service.kubernetes.io/headless": "", }, }, "addressType": "IPv4", "endpoints": []any{ map[string]any{ "addresses": []any{"10.244.1.2"}, "conditions": map[string]any{ "ready": true, }, "nodeName": "worker1", "targetRef": map[string]any{ "kind": "Pod", "name": "app-abc12", "namespace": "default", }, }, map[string]any{ "addresses": []any{"10.244.2.3", "10.244.2.4"}, "conditions": map[string]any{ "ready": true, }, "nodeName": "worker2", "targetRef": map[string]any{ "kind": "Pod", "name": "app-def34", "namespace": "default", }, }, }, "ports": []any{ map[string]any{ "name": "http", "port": int64(80), "protocol": "TCP", }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("my-headless.example.com", endpoint.RecordTypeA, "10.244.1.2", "10.244.2.3", "10.244.2.4"). WithLabel(endpoint.ResourceLabelKey, "endpointslice/default/test-headless-abc12"), }, }, { title: "fqdnTargetTemplate returns no values when condition not met", cfg: cfg{ resources: []string{"endpointslices.v1.discovery.k8s.io"}, fqdnTargetTemplate: ` {{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}} {{range $ep := .Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$ep.targetRef.name}}.pod.com:{{.}},{{end}}{{end}}{{end}}{{end}}`, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "discovery.k8s.io/v1", "kind": "EndpointSlice", "metadata": map[string]any{ "name": "regular-service-abc12", "namespace": "default", "labels": map[string]any{ "endpointslice.kubernetes.io/managed-by": "endpointslice-controller.k8s.io", "kubernetes.io/service-name": "regular-service", // Note: missing service.kubernetes.io/headless label }, }, "addressType": "IPv4", "endpoints": []any{ map[string]any{ "addresses": []any{"10.244.1.2"}, "conditions": map[string]any{ "ready": true, }, "targetRef": map[string]any{ "kind": "Pod", "name": "app-abc12", "namespace": "default", }, }, }, }, }, }, expected: nil, }, { title: "both fqdnTargetTemplate and fqdnTemplate set - endpoints from both are combined", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, fqdnTargetTemplate: `{{range $iface := .Status.interfaces}}{{$.Name}}-{{index $iface "name"}}.ifaces.example.com:{{index $iface "ipAddress"}},{{end}}`, fqdnTemplate: "{{.Name}}.vmi.example.com", targetTemplate: `{{index .Status.interfaces 0 "ipAddress"}}`, combine: true, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "my-vm", "namespace": "default", }, "status": map[string]any{ "interfaces": []any{ map[string]any{ "name": "eth0", "ipAddress": "10.244.1.50", }, map[string]any{ "name": "eth1", "ipAddress": "192.168.1.50", }, }, }, }, }, }, expected: []*endpoint.Endpoint{ // from fqdnTargetTemplate: per-interface 1:1 host:IP pairs endpoint.NewEndpoint("my-vm-eth0.ifaces.example.com", endpoint.RecordTypeA, "10.244.1.50"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/my-vm"), endpoint.NewEndpoint("my-vm-eth1.ifaces.example.com", endpoint.RecordTypeA, "192.168.1.50"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/my-vm"), // from fqdnTemplate + targetTemplate: service-level record for the primary interface endpoint.NewEndpoint("my-vm.vmi.example.com", endpoint.RecordTypeA, "10.244.1.50"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/my-vm"), }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient, dynamicClient := setupUnstructuredTestClients(t, tt.cfg.resources, tt.objects) var selector labels.Selector if tt.cfg.labelFilter != "" { var err error selector, err = labels.Parse(tt.cfg.labelFilter) require.NoError(t, err) } else { selector = labels.Everything() } src, err := NewUnstructuredFQDNSource( t.Context(), dynamicClient, kubeClient, &Config{ LabelFilter: selector, UnstructuredResources: tt.cfg.resources, FQDNTemplate: tt.cfg.fqdnTemplate, TargetTemplate: tt.cfg.targetTemplate, FQDNTargetTemplate: tt.cfg.fqdnTargetTemplate, CombineFQDNAndAnnotation: tt.cfg.combine, }, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } func TestUnstructuredWrapper_Templating(t *testing.T) { tests := []struct { name string tmpl string obj *unstructured.Unstructured want []string wantErr bool }{ { name: "typed-style Name and Namespace access", tmpl: "{{.Name}}.{{.Namespace}}.example.com", obj: &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "my-vm", "namespace": "default", }, }, }, want: []string{"my-vm.default.example.com"}, }, { name: "raw Metadata map access", tmpl: "{{.Metadata.name}}.{{.Metadata.namespace}}.example.com", obj: &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "my-vm", "namespace": "default", }, }, }, want: []string{"my-vm.default.example.com"}, }, { name: "nested Status field access", tmpl: "{{.Status.atProvider.endpoint.address}}", obj: &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "rds.aws.crossplane.io/v1alpha1", "kind": "RDSInstance", "metadata": map[string]any{ "name": "prod-db", "namespace": "default", }, "status": map[string]any{ "atProvider": map[string]any{ "endpoint": map[string]any{ "address": "prod-db.abc123.rds.amazonaws.com", }, }, }, }, }, want: []string{"prod-db.abc123.rds.amazonaws.com"}, }, { name: "array index access via Status", tmpl: `{{index .Status.interfaces 0 "ipAddress"}}`, obj: &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "vm-1", "namespace": "default", }, "status": map[string]any{ "interfaces": []any{ map[string]any{ "ipAddress": "10.244.1.50", "name": "default", }, }, }, }, }, want: []string{"10.244.1.50"}, }, { name: "typed-style Labels access", tmpl: `{{index .Labels "app.kubernetes.io/instance"}}.example.com`, obj: &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "argoproj.io/v1alpha1", "kind": "Application", "metadata": map[string]any{ "name": "guestbook", "namespace": "argocd", "labels": map[string]any{ "app.kubernetes.io/instance": "guestbook-prod", }, }, }, }, want: []string{"guestbook-prod.example.com"}, }, { name: "Kind and APIVersion access", tmpl: "{{.Kind}}.{{.APIVersion}}.example.com", obj: &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "test", "namespace": "default", }, }, }, want: []string{"VirtualMachineInstance.kubevirt.io/v1.example.com"}, }, { name: "Spec hosts array", tmpl: `{{index .Spec.hosts 0}}`, obj: &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "networking.istio.io/v1beta1", "kind": "VirtualService", "metadata": map[string]any{ "name": "reviews", "namespace": "bookinfo", }, "spec": map[string]any{ "hosts": []any{ "reviews.bookinfo.svc.cluster.local", }, }, }, }, want: []string{"reviews.bookinfo.svc.cluster.local"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpl, err := fqdn.ParseTemplate(tt.tmpl) require.NoError(t, err) wrapped := newUnstructuredWrapper(tt.obj) got, err := fqdn.ExecTemplate(tmpl, wrapped) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.want, got) }) } } ================================================ FILE: source/unstructured_test.go ================================================ /* Copyright 2026 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 source import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" discoveryfake "k8s.io/client-go/discovery/fake" "k8s.io/client-go/dynamic" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/source/types" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" ) func TestUnstructuredWrapperImplementsKubeObject(t *testing.T) { u := &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "test-vm", "namespace": "default", "labels": map[string]any{ "app": "test", }, }, }, } wrapped := newUnstructuredWrapper(u) assert.Equal(t, "test-vm", wrapped.Name) assert.Equal(t, "default", wrapped.Namespace) assert.Equal(t, "VirtualMachineInstance", wrapped.Kind) assert.Equal(t, "kubevirt.io/v1", wrapped.APIVersion) assert.Equal(t, map[string]string{"app": "test"}, wrapped.Labels) assert.Equal(t, "test-vm", wrapped.GetName()) assert.Equal(t, "default", wrapped.GetNamespace()) assert.Same(t, u, wrapped.Unstructured) // Verify it implements runtime.Object via embedding gvk := wrapped.GetObjectKind().GroupVersionKind() assert.Equal(t, "VirtualMachineInstance", gvk.Kind) } func TestUnstructured_DifferentScenarios(t *testing.T) { type cfg struct { resources []string labelSelector string annotationFilter string combine bool } for _, tt := range []struct { title string cfg cfg objects []*unstructured.Unstructured expected []*endpoint.Endpoint }{ { title: "read from annotations with IPv6 target", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "my-vm", "namespace": "default", "annotations": map[string]any{ annotations.HostnameKey: "my-vm.example.com", annotations.TargetKey: "::1234:5678", }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("my-vm.example.com", endpoint.RecordTypeAAAA, "::1234:5678"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/my-vm"), }, }, { title: "rancher node with ttl", cfg: cfg{ resources: []string{"nodes.v3.management.cattle.io"}, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "management.cattle.io/v3", "kind": "Node", "metadata": map[string]any{ "name": "my-node-1", "namespace": "cattle-system", "labels": map[string]any{ "cattle.io/creator": "norman", "node-role.kubernetes.io/controlplane": "true", }, "annotations": map[string]any{ annotations.HostnameKey: "my-node-1.nodes.example.com", annotations.TargetKey: "203.0.113.10", annotations.TtlKey: "300", }, }, "spec": map[string]any{ "clusterName": "c-abcde", "hostname": "my-node-1", }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("my-node-1.nodes.example.com", endpoint.RecordTypeA, 300, "203.0.113.10"). WithLabel(endpoint.ResourceLabelKey, "node/cattle-system/my-node-1"), }, }, { title: "with controller annotations match", cfg: cfg{ resources: []string{"replicationgroups.v1.elasticache.upbound.io"}, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "elasticache.upbound.io/v1", "kind": "ReplicationGroup", "metadata": map[string]any{ "name": "cache", "namespace": "default", "annotations": map[string]any{ annotations.HostnameKey: "my-vm.redis.tld", annotations.TargetKey: "1.1.1.0", annotations.ControllerKey: annotations.ControllerValue, }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("my-vm.redis.tld", endpoint.RecordTypeA, "1.1.1.0"). WithLabel(endpoint.ResourceLabelKey, "replicationgroup/default/cache"), }, }, { title: "with controller annotations do not match", cfg: cfg{ resources: []string{"replicationgroups.v1.elasticache.upbound.io"}, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "elasticache.upbound.io/v1", "kind": "ReplicationGroup", "metadata": map[string]any{ "name": "my-vm", "namespace": "default", "annotations": map[string]any{ annotations.HostnameKey: "my-vm.redis.tld", annotations.TargetKey: "10.10.10.0", annotations.ControllerKey: "custom-controller", }, }, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "labelSelector matches", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, labelSelector: "env=prod", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "prod-vm", "namespace": "default", "labels": map[string]any{ "env": "prod", }, "annotations": map[string]any{ annotations.HostnameKey: "prod-vm.example.com", annotations.TargetKey: "10.0.0.1", }, }, }, }, { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "dev-vm", "namespace": "default", "labels": map[string]any{ "env": "dev", }, "annotations": map[string]any{ annotations.HostnameKey: "dev-vm.example.com", annotations.TargetKey: "10.0.0.2", }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("prod-vm.example.com", endpoint.RecordTypeA, "10.0.0.1"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/prod-vm"), }, }, { title: "labelSelector no match", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, labelSelector: "env=staging", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "prod-vm", "namespace": "default", "labels": map[string]any{ "env": "prod", }, "annotations": map[string]any{ annotations.HostnameKey: "prod-vm.example.com", annotations.TargetKey: "10.0.0.1", }, }, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "annotationFilter matches", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, annotationFilter: "team=platform", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "platform-vm", "namespace": "default", "annotations": map[string]any{ "team": "platform", annotations.HostnameKey: "platform-vm.example.com", annotations.TargetKey: "10.0.0.1", }, }, }, }, { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "backend-vm", "namespace": "default", "annotations": map[string]any{ "team": "backend", annotations.HostnameKey: "backend-vm.example.com", annotations.TargetKey: "10.0.0.2", }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("platform-vm.example.com", endpoint.RecordTypeA, "10.0.0.1"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/platform-vm"), }, }, { title: "annotationFilter no match", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, annotationFilter: "team=security", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "platform-vm", "namespace": "default", "annotations": map[string]any{ "team": "platform", annotations.HostnameKey: "platform-vm.example.com", annotations.TargetKey: "10.0.0.1", }, }, }, }, }, expected: []*endpoint.Endpoint{}, }, { title: "labelSelector and annotationFilter combined", cfg: cfg{ resources: []string{"virtualmachineinstances.v1.kubevirt.io"}, labelSelector: "env=prod", annotationFilter: "team=platform", }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "prod-platform-vm", "namespace": "default", "labels": map[string]any{ "env": "prod", }, "annotations": map[string]any{ "team": "platform", annotations.HostnameKey: "prod-platform-vm.example.com", annotations.TargetKey: "10.0.0.1", }, }, }, }, { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "prod-backend-vm", "namespace": "default", "labels": map[string]any{ "env": "prod", }, "annotations": map[string]any{ "team": "backend", annotations.HostnameKey: "prod-backend-vm.example.com", annotations.TargetKey: "10.0.0.2", }, }, }, }, { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "dev-platform-vm", "namespace": "default", "labels": map[string]any{ "env": "dev", }, "annotations": map[string]any{ "team": "platform", annotations.HostnameKey: "dev-platform-vm.example.com", annotations.TargetKey: "10.0.0.3", }, }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("prod-platform-vm.example.com", endpoint.RecordTypeA, "10.0.0.1"). WithLabel(endpoint.ResourceLabelKey, "virtualmachineinstance/default/prod-platform-vm"), }, }, { title: "provider-specific annotation is not supported and is ignored", cfg: cfg{ resources: []string{"machines.v1beta1.cluster.x-k8s.io"}, }, objects: []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "cluster.x-k8s.io/v1beta1", "kind": "Machine", "metadata": map[string]any{ "name": "control-plane", "namespace": "default", "labels": map[string]any{ "cluster.x-k8s.io/cluster-name": "test-cluster", "cluster.x-k8s.io/control-plane": "", }, "annotations": map[string]any{ annotations.HostnameKey: "control-plane.example.com", annotations.TargetKey: "10.0.0.1", annotations.CloudflarePrefix: "cloudflare-specific-annotation", }, }, "spec": map[string]any{ "clusterName": "test-cluster", "bootstrap": map[string]any{ "dataSecretName": "control-plane-bootstrap", }, "version": "v1.26.0", }, }, }, }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("control-plane.example.com", endpoint.RecordTypeA, "10.0.0.1"). WithLabel(endpoint.ResourceLabelKey, "machine/default/control-plane"), }, }, } { t.Run(tt.title, func(t *testing.T) { kubeClient, dynamicClient := setupUnstructuredTestClients(t, tt.cfg.resources, tt.objects) labelSelector := labels.Everything() if tt.cfg.labelSelector != "" { var err error labelSelector, err = labels.Parse(tt.cfg.labelSelector) require.NoError(t, err) } src, err := NewUnstructuredFQDNSource( t.Context(), dynamicClient, kubeClient, &Config{ AnnotationFilter: tt.cfg.annotationFilter, LabelFilter: labelSelector, UnstructuredResources: tt.cfg.resources, CombineFQDNAndAnnotation: tt.cfg.combine, }, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) for _, ep := range endpoints { require.Contains(t, ep.Labels, endpoint.ResourceLabelKey) } }) } } func TestProcessEndpoint_Unstructured_RefObjectExist(t *testing.T) { resources := []string{"virtualmachineinstances.v1.kubevirt.io"} objects := []*unstructured.Unstructured{ { Object: map[string]any{ "apiVersion": "kubevirt.io/v1", "kind": "VirtualMachineInstance", "metadata": map[string]any{ "name": "prod-platform-vm", "namespace": "default", "labels": map[string]any{ "env": "prod", }, "annotations": map[string]any{ "team": "platform", annotations.HostnameKey: "prod-platform-vm.example.com", annotations.TargetKey: "10.0.0.1", }, "uid": "12345", }, }, }, } kubeClient, dynamicClient := setupUnstructuredTestClients(t, resources, objects) src, err := NewUnstructuredFQDNSource( t.Context(), dynamicClient, kubeClient, &Config{ LabelFilter: labels.Everything(), UnstructuredResources: resources, }, ) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) testutils.AssertEndpointsHaveRefObject(t, endpoints, types.Unstructured, len(objects)) } func TestEndpointsForHostsAndTargets(t *testing.T) { tests := []struct { name string hostnames []string targets []string expected []*endpoint.Endpoint }{ { name: "empty hostnames returns nil", hostnames: []string{}, targets: []string{"192.168.1.1"}, expected: nil, }, { name: "empty targets returns nil", hostnames: []string{"example.com"}, targets: []string{}, expected: nil, }, { name: "duplicate hostname with IPv4 and IPv6 targets", hostnames: []string{"example.com", "example.com"}, targets: []string{"192.168.1.1", "192.168.1.1", "2001:db8::1"}, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "192.168.1.1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001:db8::1"), }, }, { name: "multiple hostnames with single target", hostnames: []string{"example.com", "www.example.com"}, targets: []string{"192.168.1.1"}, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "192.168.1.1"), endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "192.168.1.1"), }, }, { name: "multiple of each type maintains grouping", hostnames: []string{"example.com"}, targets: []string{"192.168.1.1", "192.168.1.2", "2001:db8::1", "2001:db8::2", "a.example.com", "b.example.com"}, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "192.168.1.1", "192.168.1.2"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001:db8::1", "2001:db8::2"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.example.com", "b.example.com"), }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := EndpointsForHostsAndTargets(tc.hostnames, tc.targets) if tc.expected == nil { assert.Nil(t, result) return } validateEndpoints(t, result, tc.expected) }) } } // setupUnstructuredTestClients creates fake kube and dynamic clients with the given resources and objects. func setupUnstructuredTestClients(t *testing.T, resources []string, objects []*unstructured.Unstructured) ( kubernetes.Interface, dynamic.Interface, ) { t.Helper() // Parse all resource identifiers and build apiVersion → GVR map in one pass gvrs := make([]schema.GroupVersionResource, 0, len(resources)) apiVersionToGVR := make(map[string]schema.GroupVersionResource, len(resources)) for _, res := range resources { if strings.Count(res, ".") == 1 { res += "." } gvr, _ := schema.ParseResourceArg(res) require.NotNil(t, gvr, "invalid resource identifier: %s", res) gvrs = append(gvrs, *gvr) apiVersionToGVR[gvr.GroupVersion().String()] = *gvr } // Derive kind and list kind from objects gvrToKind := make(map[schema.GroupVersionResource]string, len(gvrs)) gvrToListKind := make(map[schema.GroupVersionResource]string, len(gvrs)) for _, obj := range objects { if gvr, ok := apiVersionToGVR[obj.GetAPIVersion()]; ok { gvrToKind[gvr] = obj.GetKind() gvrToListKind[gvr] = obj.GetKind() + "List" } } // Build discovery resource lists apiResourceLists := make([]*metav1.APIResourceList, 0, len(gvrs)) for _, gvr := range gvrs { apiResourceLists = append(apiResourceLists, &metav1.APIResourceList{ GroupVersion: gvr.GroupVersion().String(), APIResources: []metav1.APIResource{{ Name: gvr.Resource, Namespaced: true, Kind: gvrToKind[gvr], }}, }) } kubeClient := fake.NewClientset() kubeClient.Discovery().(*discoveryfake.FakeDiscovery).Resources = apiResourceLists dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), gvrToListKind) for _, obj := range objects { gvr, ok := apiVersionToGVR[obj.GetAPIVersion()] require.True(t, ok, "no resource found for apiVersion %s", obj.GetAPIVersion()) _, err := dynamicClient.Resource(gvr).Namespace(obj.GetNamespace()).Create( t.Context(), obj, metav1.CreateOptions{}) require.NoError(t, err) } return kubeClient, dynamicClient } ================================================ FILE: source/utils.go ================================================ /* 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 source import ( "fmt" "slices" "strings" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" ) // ParseIngress parses an ingress string in the format "namespace/name" or "name". // It returns the namespace and name extracted from the string, or an error if the format is invalid. // If the namespace is not provided, it defaults to an empty string. func ParseIngress(ingress string) (string, string, error) { var namespace, name string var err error parts := strings.Split(ingress, "/") switch len(parts) { case 2: namespace, name = parts[0], parts[1] case 1: name = parts[0] default: err = fmt.Errorf("invalid ingress name (name or namespace/name) found %q", ingress) } return namespace, name, err } // MatchesServiceSelector checks if all key-value pairs in the selector map // are present and match the corresponding key-value pairs in the svcSelector map. // It returns true if all pairs match, otherwise it returns false. func MatchesServiceSelector(selector, svcSelector map[string]string) bool { for k, v := range selector { if lbl, ok := svcSelector[k]; !ok || lbl != v { return false } } return true } // MergeEndpoints merges endpoints with the same key (DNSName + RecordType + SetIdentifier + RecordTTL) // by combining their targets. CNAME endpoints are not merged (per DNS spec) but are deduplicated. // This is useful when multiple resources (e.g., pods, nodes) contribute targets to the same DNS record. // // TODO: move this to endpoint/utils.go func MergeEndpoints(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint { if len(endpoints) == 0 { return endpoints } endpointMap := make(map[endpoint.EndpointKey]*endpoint.Endpoint) cnameTargets := make(map[string]string) // DNSName+SetIdentifier -> first target seen for _, ep := range endpoints { if ep.RecordType == endpoint.RecordTypeCNAME && len(ep.Targets) == 0 { log.Debugf("Skipping CNAME endpoint %q with no targets", ep.DNSName) continue } key := endpoint.EndpointKey{ DNSName: ep.DNSName, RecordType: ep.RecordType, SetIdentifier: ep.SetIdentifier, RecordTTL: ep.RecordTTL, } // CNAME records can only have one target per DNS spec, and they should not be merged. if ep.RecordType == endpoint.RecordTypeCNAME { key.Target = ep.Targets[0] cnameKey := ep.DNSName + "/" + ep.SetIdentifier if existing, ok := cnameTargets[cnameKey]; ok && existing != ep.Targets[0] { // This will be caught by the provider when it tries to create the record, but log a warning here to make it more obvious. // TODO: add metric for CNAME conflicts log.Warnf("Only one CNAME per name — %s CNAME %s and %s CNAME %s is invalid DNS. A resolver wouldn't know which canonical name to follow.", ep.DNSName, existing, ep.DNSName, ep.Targets[0]) } cnameTargets[cnameKey] = ep.Targets[0] } if existing, ok := endpointMap[key]; ok { existing.Targets = append(existing.Targets, ep.Targets...) } else { endpointMap[key] = ep } } result := make([]*endpoint.Endpoint, 0, len(endpointMap)) for _, ep := range endpointMap { slices.Sort(ep.Targets) ep.Targets = slices.Compact(ep.Targets) result = append(result, ep) } return result } ================================================ FILE: source/utils_test.go ================================================ /* 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 source import ( "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/source/types" ) func TestParseIngress(t *testing.T) { tests := []struct { name string ingress string wantNS string wantName string wantError bool }{ { name: "valid namespace and name", ingress: "default/test-ingress", wantNS: "default", wantName: "test-ingress", wantError: false, }, { name: "only name provided", ingress: "test-ingress", wantNS: "", wantName: "test-ingress", wantError: false, }, { name: "invalid format", ingress: "default/test/ingress", wantNS: "", wantName: "", wantError: true, }, { name: "empty string", ingress: "", wantNS: "", wantName: "", wantError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotNS, gotName, err := ParseIngress(tt.ingress) if tt.wantError { assert.Error(t, err) } else { assert.NoError(t, err) } assert.Equal(t, tt.wantNS, gotNS) assert.Equal(t, tt.wantName, gotName) }) } } func TestSelectorMatchesService(t *testing.T) { tests := []struct { name string selector map[string]string svcSelector map[string]string expected bool }{ { name: "all key-value pairs match", selector: map[string]string{"app": "nginx", "env": "prod"}, svcSelector: map[string]string{"app": "nginx", "env": "prod"}, expected: true, }, { name: "one key-value pair does not match", selector: map[string]string{"app": "nginx", "env": "prod"}, svcSelector: map[string]string{"app": "nginx", "env": "dev"}, expected: false, }, { name: "key not present in svcSelector", selector: map[string]string{"app": "nginx", "env": "prod"}, svcSelector: map[string]string{"app": "nginx"}, expected: false, }, { name: "empty selector", selector: map[string]string{}, svcSelector: map[string]string{"app": "nginx", "env": "prod"}, expected: true, }, { name: "empty svcSelector", selector: map[string]string{"app": "nginx", "env": "prod"}, svcSelector: map[string]string{}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MatchesServiceSelector(tt.selector, tt.svcSelector) assert.Equal(t, tt.expected, result) }) } } func TestMergeEndpoints(t *testing.T) { tests := []struct { name string input []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { name: "nil input returns nil", input: nil, expected: nil, }, { name: "empty input returns empty", input: []*endpoint.Endpoint{}, expected: []*endpoint.Endpoint{}, }, { name: "single endpoint unchanged", input: []*endpoint.Endpoint{ {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { name: "different keys not merged", input: []*endpoint.Endpoint{ {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "b.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "b.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, }, }, { name: "same DNSName different RecordType not merged", input: []*endpoint.Endpoint{ {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "example.com", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, }, }, { name: "same key merged with sorted targets", input: []*endpoint.Endpoint{ {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"5.6.7.8"}}, {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4", "5.6.7.8"}}, }, }, { name: "multiple endpoints same key merged", input: []*endpoint.Endpoint{ {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"3.3.3.3"}}, {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"2.2.2.2"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "2.2.2.2", "3.3.3.3"}}, }, }, { name: "mixed merge and no merge", input: []*endpoint.Endpoint{ {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}}, {DNSName: "b.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"2.2.2.2"}}, {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"3.3.3.3"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "a.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "3.3.3.3"}}, {DNSName: "b.example.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"2.2.2.2"}}, }, }, { name: "duplicate targets deduplicated", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "1.2.3.4", "5.6.7.8"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8"), }, }, { name: "duplicate targets across merged endpoints deduplicated", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8"), }, }, { name: "CNAME endpoints not merged", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "b.elb.com"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "b.elb.com"), }, }, { name: "CNAME with no targets is skipped", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), }, }, { name: "identical CNAME endpoints deduplicated", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), }, }, { name: "same key with different TTL not merged", input: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 300, "1.2.3.4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 600, "5.6.7.8"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 300, "1.2.3.4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, 600, "5.6.7.8"), }, }, { name: "same DNSName and RecordType with different SetIdentifier not merged", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("eu-west-1"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("eu-west-1"), }, }, { name: "same DNSName, RecordType and SetIdentifier targets are merged", input: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4").WithSetIdentifier("us-east-1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "5.6.7.8").WithSetIdentifier("us-east-1"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4", "5.6.7.8").WithSetIdentifier("us-east-1"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MergeEndpoints(tt.input) assert.ElementsMatch(t, tt.expected, result) }) } } func TestMergeEndpoints_RefObjects(t *testing.T) { tests := []struct { name string input func() []*endpoint.Endpoint expected func(*testing.T, []*endpoint.Endpoint) }{ { name: "empty input", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{} }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { assert.Empty(t, ep) }, }, { name: "single endpoint", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default", UID: "123"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { assert.Len(t, ep, 1) assert.Equal(t, types.Service, ep[0].RefObject().Source) assert.Equal(t, "foo", ep[0].RefObject().Name) assert.Equal(t, "123", string(ep[0].RefObject().UID)) }, }, { name: "two endpoints merged and only single refObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("a.example.com", "1.1.1.1", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default", UID: "123"}, }, types.Service), testutils.NewEndpointWithRef("a.example.com", "1.1.1.1", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "ns", UID: "345"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { assert.Len(t, ep, 1) assert.Equal(t, types.Service, ep[0].RefObject().Source) assert.Equal(t, "foo", ep[0].RefObject().Name) assert.Equal(t, "123", string(ep[0].RefObject().UID)) assert.NotEqual(t, "345", string(ep[0].RefObject().UID)) }, }, { name: "two endpoints not merged and two refObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("a.example.com", "1.1.1.1", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default", UID: "123"}, }, types.Service), testutils.NewEndpointWithRef("b.example.com", "1.1.1.2", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "bar", Namespace: "ns", UID: "345"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { assert.Len(t, ep, 2) assert.NotEqual(t, ep[0], ep[1]) for _, el := range ep { assert.Equal(t, types.Service, el.RefObject().Source) assert.Contains(t, []string{"foo", "bar"}, el.RefObject().Name) assert.Contains(t, []string{"123", "345"}, string(el.RefObject().UID)) } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := MergeEndpoints(tt.input()) tt.expected(t, result) }) } } func TestMergeEndpointsLogging(t *testing.T) { t.Run("warns on CNAME conflict", func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) MergeEndpoints([]*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "b.elb.com"), }) logtest.TestHelperLogContainsWithLogLevel("Only one CNAME per name", log.WarnLevel, hook, t) logtest.TestHelperLogContains("example.com CNAME a.elb.com", hook, t) logtest.TestHelperLogContains("example.com CNAME b.elb.com", hook, t) }) t.Run("no warning for identical CNAMEs", func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) MergeEndpoints([]*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com"), }) logtest.TestHelperLogNotContains("Only one CNAME per name", hook, t) }) t.Run("no warning for same DNSName with different SetIdentifier", func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) MergeEndpoints([]*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "a.elb.com").WithSetIdentifier("weight-1"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "b.elb.com").WithSetIdentifier("weight-2"), }) logtest.TestHelperLogNotContains("Only one CNAME per name", hook, t) }) t.Run("debug log for CNAME with no targets", func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) MergeEndpoints([]*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME), }) logtest.TestHelperLogContainsWithLogLevel("Skipping CNAME endpoint", log.DebugLevel, hook, t) logtest.TestHelperLogContains("example.com", hook, t) }) } ================================================ FILE: source/wrappers/dedupsource.go ================================================ /* Copyright 2017 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 wrappers import ( "context" "strings" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source" ) // dedupSource is a Source that removes duplicate endpoints from its wrapped source. type dedupSource struct { source source.Source } // NewDedupSource creates a new dedupSource wrapping the provided Source. func NewDedupSource(source source.Source) source.Source { return &dedupSource{source: source} } // Endpoints collects endpoints from its wrapped source and returns them without duplicates. func (ms *dedupSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { log.Debug("dedupSource: collecting endpoints and removing duplicates") result := make([]*endpoint.Endpoint, 0) collected := make(map[string]struct{}) endpoints, err := ms.source.Endpoints(ctx) if err != nil { return nil, err } for _, ep := range endpoints { if ep == nil { continue } // validate endpoint before normalization if ok := ep.CheckEndpoint(); !ok { log.Warnf("Skipping endpoint [%s:%s] due to invalid configuration [%s:%s]", ep.SetIdentifier, ep.DNSName, ep.RecordType, strings.Join(ep.Targets, ",")) continue } if len(ep.Targets) > 1 { ep.Targets = endpoint.NewTargets(ep.Targets...) } identifier := strings.Join([]string{ep.RecordType, ep.DNSName, ep.SetIdentifier, ep.Targets.String()}, "/") if _, ok := collected[identifier]; ok { log.Debugf("Removing duplicate endpoint %s", ep) continue } collected[identifier] = struct{}{} result = append(result, ep) } return result, nil } func (ms *dedupSource) AddEventHandler(ctx context.Context, handler func()) { log.Debug("dedupSource: adding event handler") ms.source.AddEventHandler(ctx, handler) } ================================================ FILE: source/wrappers/dedupsource_test.go ================================================ /* Copyright 2017 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 wrappers import ( "testing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" v1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" logtest "sigs.k8s.io/external-dns/internal/testutils/log" "sigs.k8s.io/external-dns/source" "sigs.k8s.io/external-dns/source/types" ) // Validates that dedupSource is a Source var _ source.Source = &dedupSource{} // TestDedupEndpoints tests that duplicates from the wrapped source are removed. func TestDedupEndpoints(t *testing.T) { for _, tc := range []struct { title string endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { "one endpoint returns one endpoint", []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { "two different endpoints return two endpoints", []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", Targets: endpoint.Targets{"4.5.6.7"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", Targets: endpoint.Targets{"4.5.6.7"}}, }, }, { "two endpoints with same dnsname and different targets return two endpoints", []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"4.5.6.7"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"4.5.6.7"}}, }, }, { "two endpoints with different dnsname and same target return two endpoints", []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "bar.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { "two endpoints with same dnsname and same target return one endpoint", []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { "two endpoints with same dnsname, same type, and same target return one endpoint", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { "two endpoints with same dnsname, different record type, and same target return two endpoints", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, }, }, { "two endpoints with same dnsname, one with record type, one without, and same target return two endpoints", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, {DNSName: "foo.example.org", Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { "two endpoints with same dnsname, same type, same target but different SetIdentifier return two endpoints", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "eu-west-1"}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "eu-west-1"}, }, }, { "two endpoints with same dnsname, same type, same target and same SetIdentifier return one endpoint", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, SetIdentifier: "us-east-1"}, }, }, { "no endpoints returns empty endpoints", []*endpoint.Endpoint{}, []*endpoint.Endpoint{}, }, { "one endpoint with multiple targets returns one endpoint and targets without duplicates", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4", "34.66.66.77", "34.66.66.77"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4", "34.66.66.77"}}, }, }, } { t.Run(tc.title, func(t *testing.T) { mockSource := new(testutils.MockSource) mockSource.On("Endpoints").Return(tc.endpoints, nil) // Create our object under test and get the endpoints. source := NewDedupSource(mockSource) endpoints, err := source.Endpoints(t.Context()) if err != nil { t.Fatal(err) } // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) // Validate that the mock source was called. mockSource.AssertExpectations(t) }) } } func TestDedupSource_AddEventHandler(t *testing.T) { tests := []struct { title string input []string times int }{ { title: "should add event handler", times: 1, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { mockSource := testutils.NewMockSource() src := NewDedupSource(mockSource) src.AddEventHandler(t.Context(), func() {}) mockSource.AssertNumberOfCalls(t, "AddEventHandler", tt.times) }) } } func TestDedupEndpointsValidation(t *testing.T) { tests := []struct { name string endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { name: "mix of SRV records", endpoints: []*endpoint.Endpoint{ {DNSName: "_service._tcp.example.org", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"10 5 443 target.example.org."}}, // valid {DNSName: "_service._tcp.example.org", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"11 5 target.example.org"}}, // invalid }, expected: []*endpoint.Endpoint{ {DNSName: "_service._tcp.example.org", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"10 5 443 target.example.org."}}, }, }, { name: "invalid SRV record - missing priority", endpoints: []*endpoint.Endpoint{ {DNSName: "_service._tcp.example.org", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"5 443 target.example.org"}}, }, expected: []*endpoint.Endpoint{}, }, { name: "valid MX record", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"10 mail.example.org"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"10 mail.example.org"}}, }, }, { name: "invalid MX record - missing priority", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"mail.example.org"}}, }, expected: []*endpoint.Endpoint{}, }, { name: "valid NAPTR record", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeNAPTR, Targets: endpoint.Targets{"100 10 \"u\" \"E2U+sip\" \"!^.*$!sip:info@example.org!\" ."}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeNAPTR, Targets: endpoint.Targets{"100 10 \"u\" \"E2U+sip\" \"!^.*$!sip:info@example.org!\" ."}}, }, }, { name: "invalid NAPTR record - incomplete format", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeNAPTR, Targets: endpoint.Targets{"100 10 \"u\""}}, // invalid }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeNAPTR, Targets: endpoint.Targets{"100 10 \"u\""}}, }, }, { name: "mixed valid and invalid records", endpoints: []*endpoint.Endpoint{ {DNSName: "_service._tcp.example.org", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"10 5 443"}}, // invalid {DNSName: "example.org", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"mail.example.org"}}, // invalid {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, }, { name: "MX record with alias=true is filtered out", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"10 mail.example.org"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: "alias", Value: "true"}}}, }, expected: []*endpoint.Endpoint{}, }, { name: "A record with alias=true is kept", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: "alias", Value: "true"}}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.1"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: "alias", Value: "true"}}}, }, }, { name: "SRV record with alias=true is filtered out", endpoints: []*endpoint.Endpoint{ {DNSName: "_sip._tcp.example.org", RecordType: endpoint.RecordTypeSRV, Targets: endpoint.Targets{"10 5 5060 sip.example.org."}, ProviderSpecific: endpoint.ProviderSpecific{{Name: "alias", Value: "true"}}}, }, expected: []*endpoint.Endpoint{}, }, { name: "mixed valid and invalid TXT, A, AAAA records", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"v=spf1 include:example.com ~all"}}, // valid {DNSName: "example.org", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{""}}, // invalid {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.1"}}, // valid {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"not-an-ip"}}, // invalid {DNSName: "example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, // valid {DNSName: "example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"invalid-ipv6"}}, // invalid }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{"v=spf1 include:example.com ~all"}}, {DNSName: "example.org", RecordType: endpoint.RecordTypeTXT, Targets: endpoint.Targets{""}}, {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.168.1.1"}}, {DNSName: "example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}}, }, }, { name: "valid PTR record with reverse DNS name", endpoints: []*endpoint.Endpoint{ {DNSName: "2.49.168.192.in-addr.arpa", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{"web.example.com"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "2.49.168.192.in-addr.arpa", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{"web.example.com"}}, }, }, { name: "invalid PTR record - non-reverse DNS name", endpoints: []*endpoint.Endpoint{ {DNSName: "web.example.com", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{"other.example.com"}}, }, expected: []*endpoint.Endpoint{}, }, { name: "invalid PTR record - target is an IP", endpoints: []*endpoint.Endpoint{ {DNSName: "1.0.0.10.in-addr.arpa", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{"10.0.0.1"}}, }, expected: []*endpoint.Endpoint{}, }, { name: "A record with record-type annotation passes through", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: "PTR"}}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: "PTR"}}}, }, }, { name: "duplicate A records with same record-type annotation are deduped", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: "PTR"}}}, {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: "PTR"}}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: "PTR"}}}, }, }, { name: "A records with and without record-type annotation are deduped by identity key", endpoints: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: "PTR"}}}, {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, expected: []*endpoint.Endpoint{ {DNSName: "example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: endpoint.ProviderSpecificRecordType, Value: "PTR"}}}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockSource := new(testutils.MockSource) mockSource.On("Endpoints").Return(tt.endpoints, nil) sr := NewDedupSource(mockSource) endpoints, err := sr.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) mockSource.AssertExpectations(t) }) } } func TestDedupSource_WarnsOnInvalidEndpoint(t *testing.T) { tests := []struct { name string endpoint *endpoint.Endpoint wantLogMsg string }{ { name: "invalid SRV record", endpoint: &endpoint.Endpoint{ DNSName: "example.org", RecordType: endpoint.RecordTypeSRV, SetIdentifier: "default/svc/my-service", Targets: endpoint.Targets{"10 mail.example.org"}, }, wantLogMsg: "Skipping endpoint [default/svc/my-service:example.org] due to invalid configuration [SRV:10 mail.example.org]", }, { name: "unsupported alias on MX record", endpoint: &endpoint.Endpoint{ DNSName: "example.org", RecordType: endpoint.RecordTypeMX, Targets: endpoint.Targets{"10 mail.example.org"}, ProviderSpecific: endpoint.ProviderSpecific{{Name: "alias", Value: "true"}}, }, wantLogMsg: "Endpoint example.org of type MX does not support alias records", }, { name: "invalid PTR record with non-reverse DNS name", endpoint: &endpoint.Endpoint{ DNSName: "web.example.org", RecordType: endpoint.RecordTypePTR, Targets: endpoint.Targets{"other.example.org"}, }, wantLogMsg: "Skipping endpoint [:web.example.org] due to invalid configuration [PTR:other.example.org]", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { hook := logtest.LogsUnderTestWithLogLevel(log.WarnLevel, t) mockSource := new(testutils.MockSource) mockSource.On("Endpoints").Return([]*endpoint.Endpoint{tt.endpoint}, nil) src := NewDedupSource(mockSource) _, err := src.Endpoints(t.Context()) require.NoError(t, err) logtest.TestHelperLogContains(tt.wantLogMsg, hook, t) }) } } func TestDedupSource_RefObjects(t *testing.T) { tests := []struct { name string input func() []*endpoint.Endpoint expected func(*testing.T, []*endpoint.Endpoint) }{ { name: "empty input", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{} }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Empty(t, ep) }, }, { name: "single endpoint with RefObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "default", UID: "123"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Len(t, ep, 1) require.NotNil(t, ep[0].RefObject()) require.Equal(t, types.Service, ep[0].RefObject().Source) require.Equal(t, "foo", ep[0].RefObject().Name) require.Equal(t, "123", string(ep[0].RefObject().UID)) }, }, { name: "duplicate endpoints with same source type - first RefObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "first-svc", Namespace: "default", UID: "uid-first"}, }, types.Service), testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "second-svc", Namespace: "other", UID: "uid-second"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Len(t, ep, 1) require.NotNil(t, ep[0].RefObject()) require.Equal(t, types.Service, ep[0].RefObject().Source) require.Equal(t, "first-svc", ep[0].RefObject().Name) require.Equal(t, "uid-first", string(ep[0].RefObject().UID)) }, }, { name: "duplicate endpoints with different source types - first RefObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "default", UID: "svc-uid"}, }, types.Service), testutils.NewEndpointWithRef("example.com", "1.2.3.4", &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{Name: "my-ingress", Namespace: "default", UID: "ing-uid"}, }, types.Ingress), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Len(t, ep, 1) require.NotNil(t, ep[0].RefObject()) // First endpoint (Service) wins, Ingress is discarded require.Equal(t, types.Service, ep[0].RefObject().Source) require.Equal(t, "my-service", ep[0].RefObject().Name) require.Equal(t, "svc-uid", string(ep[0].RefObject().UID)) }, }, { name: "duplicate endpoints - Ingress first, Service second - Ingress RefObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("example.com", "1.2.3.4", &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{Name: "my-ingress", Namespace: "default", UID: "ing-uid"}, }, types.Ingress), testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "default", UID: "svc-uid"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Len(t, ep, 1) require.NotNil(t, ep[0].RefObject()) // First endpoint (Ingress) wins, Service is discarded require.Equal(t, types.Ingress, ep[0].RefObject().Source) require.Equal(t, "my-ingress", ep[0].RefObject().Name) require.Equal(t, "ing-uid", string(ep[0].RefObject().UID)) }, }, { name: "non-duplicate endpoints with different source types - both RefObjects preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("a.example.com", "1.1.1.1", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "default", UID: "123"}, }, types.Service), testutils.NewEndpointWithRef("b.example.com", "2.2.2.2", &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{Name: "my-ingress", Namespace: "default", UID: "234"}, }, types.Ingress), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Len(t, ep, 2) // Find endpoints by DNS name since order may vary var svcEndpoint, ingEndpoint *endpoint.Endpoint for _, e := range ep { if e.DNSName == "a.example.com" { svcEndpoint = e } else if e.DNSName == "b.example.com" { ingEndpoint = e } } require.NotNil(t, svcEndpoint) require.NotNil(t, svcEndpoint.RefObject()) require.Equal(t, types.Service, svcEndpoint.RefObject().Source) require.Equal(t, "my-service", svcEndpoint.RefObject().Name) require.NotNil(t, ingEndpoint) require.NotNil(t, ingEndpoint.RefObject()) require.Equal(t, types.Ingress, ingEndpoint.RefObject().Source) require.Equal(t, "my-ingress", ingEndpoint.RefObject().Name) }, }, { name: "three duplicate endpoints from different sources - first RefObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "default", UID: "123"}, }, types.Service), testutils.NewEndpointWithRef("example.com", "1.2.3.4", &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{Name: "my-ingress", Namespace: "default", UID: "345"}, }, types.Ingress), testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: "my-pod", Namespace: "default", UID: "456"}, }, types.Pod), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Len(t, ep, 1) require.NotNil(t, ep[0].RefObject()) // First endpoint (Service) wins require.Equal(t, types.Service, ep[0].RefObject().Source) require.Equal(t, "my-service", ep[0].RefObject().Name) require.Equal(t, "123", string(ep[0].RefObject().UID)) }, }, { name: "duplicate endpoints with one having nil RefObject - first RefObject preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "default", UID: "123"}, }, types.Service), endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Len(t, ep, 1) require.NotNil(t, ep[0].RefObject()) require.Equal(t, types.Service, ep[0].RefObject().Source) require.Equal(t, "123", string(ep[0].RefObject().UID)) }, }, { name: "duplicate endpoints with first having nil RefObject - nil preserved", input: func() []*endpoint.Endpoint { return []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), testutils.NewEndpointWithRef("example.com", "1.2.3.4", &v1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "default", UID: "345"}, }, types.Service), } }, expected: func(t *testing.T, ep []*endpoint.Endpoint) { require.Len(t, ep, 1) // First endpoint (without RefObject) wins require.Nil(t, ep[0].RefObject()) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockSource := new(testutils.MockSource) mockSource.On("Endpoints").Return(tt.input(), nil) src := NewDedupSource(mockSource) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) tt.expected(t, endpoints) mockSource.AssertExpectations(t) }) } } ================================================ FILE: source/wrappers/multisource.go ================================================ /* Copyright 2017 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 wrappers import ( "context" "reflect" "strings" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source" ) // multiSource is a Source that merges the endpoints of its nested Sources. type multiSource struct { children []source.Source defaultTargets []string forceDefaultTargets bool } // Endpoints collects endpoints of all nested Sources and returns them in a single slice. func (ms *multiSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { log.Debugf("multiSource: collecting endpoints from %d child sources and removing duplicates", len(ms.children)) result := []*endpoint.Endpoint{} hasDefaultTargets := len(ms.defaultTargets) > 0 for _, s := range ms.children { endpoints, err := s.Endpoints(ctx) if err != nil { return nil, err } if !hasDefaultTargets { result = append(result, endpoints...) continue } for _, ep := range endpoints { hasSourceTargets := len(ep.Targets) > 0 if ms.forceDefaultTargets || !hasSourceTargets { eps := endpoint.EndpointsForHostname(ep.DNSName, ms.defaultTargets, ep.RecordTTL, ep.ProviderSpecific, ep.SetIdentifier, "") for _, e := range eps { e.Labels = ep.Labels } result = append(result, eps...) continue } log.Warnf("Source provided targets for %q (%s), ignoring default targets [%s] due to new behavior. Use --force-default-targets to revert to old behavior.", ep.DNSName, ep.RecordType, strings.Join(ms.defaultTargets, ", ")) result = append(result, ep) } } return result, nil } func (ms *multiSource) AddEventHandler(ctx context.Context, handler func()) { log.Debugf("multiSource: adding event handler for %d child sources", len(ms.children)) for _, s := range ms.children { log.Debugf("multiSource: adding event handler for child %q", reflect.TypeOf(s).String()) s.AddEventHandler(ctx, handler) } } // NewMultiSource creates a new multiSource. func NewMultiSource(children []source.Source, defaultTargets []string, forceDefaultTargets bool) source.Source { return &multiSource{children: children, defaultTargets: defaultTargets, forceDefaultTargets: forceDefaultTargets} } ================================================ FILE: source/wrappers/multisource_test.go ================================================ /* Copyright 2017 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 wrappers import ( "errors" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/source" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" ) func TestMultiSource(t *testing.T) { t.Parallel() t.Run("Interface", testMultiSourceImplementsSource) t.Run("Endpoints", testMultiSourceEndpoints) t.Run("EndpointsWithError", testMultiSourceEndpointsWithError) t.Run("EndpointsDefaultTargets", testMultiSourceEndpointsDefaultTargets) } // testMultiSourceImplementsSource tests that multiSource is a valid Source. func testMultiSourceImplementsSource(t *testing.T) { assert.Implements(t, (*source.Source)(nil), new(multiSource)) } // testMultiSourceEndpoints tests merged endpoints from children are returned. func testMultiSourceEndpoints(t *testing.T) { foo := &endpoint.Endpoint{DNSName: "foo", Targets: endpoint.Targets{"8.8.8.8"}} bar := &endpoint.Endpoint{DNSName: "bar", Targets: endpoint.Targets{"8.8.4.4"}} for _, tc := range []struct { title string nestedEndpoints [][]*endpoint.Endpoint expected []*endpoint.Endpoint }{ { "no child sources return no endpoints", nil, []*endpoint.Endpoint{}, }, { "single empty child source returns no endpoints", [][]*endpoint.Endpoint{{}}, []*endpoint.Endpoint{}, }, { "single non-empty child source returns child's endpoints", [][]*endpoint.Endpoint{{foo.DeepCopy()}}, []*endpoint.Endpoint{foo.DeepCopy()}, }, { "multiple non-empty child sources returns merged children's endpoints", [][]*endpoint.Endpoint{{foo.DeepCopy()}, {bar.DeepCopy()}}, []*endpoint.Endpoint{foo.DeepCopy(), bar.DeepCopy()}, }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() // Prepare the nested mock sources. sources := make([]source.Source, 0, len(tc.nestedEndpoints)) // Populate the nested mock sources. for _, endpoints := range tc.nestedEndpoints { src := new(testutils.MockSource) src.On("Endpoints").Return(endpoints, nil) sources = append(sources, src) } // Create our object under test and get the endpoints. source := NewMultiSource(sources, nil, false) // Get endpoints from the source. endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) // Validate that the nested sources were called. for _, src := range sources { src.(*testutils.MockSource).AssertExpectations(t) } }) } } // testMultiSourceEndpointsWithError tests that an error by a nested source is bubbled up. func testMultiSourceEndpointsWithError(t *testing.T) { // Create the expected error. errSomeError := errors.New("some error") // Create a mocked source returning that error. src := new(testutils.MockSource) src.On("Endpoints").Return(nil, errSomeError) // Create our object under test and get the endpoints. source := NewMultiSource([]source.Source{src}, nil, false) // Get endpoints from our source. _, err := source.Endpoints(t.Context()) assert.EqualError(t, err, "some error") // Validate that the nested source was called. src.AssertExpectations(t) } func testMultiSourceEndpointsDefaultTargets(t *testing.T) { t.Run("Defaults applied when source targets are empty", func(t *testing.T) { defaultTargetsA := []string{"127.0.0.1", "127.0.0.2"} defaultTargetsAAAA := []string{"2001:db8::1"} defaultTargetsCName := []string{"foo.example.org"} defaultTargets := append(defaultTargetsA, defaultTargetsCName...) // nolint: gocritic // appendAssign defaultTargets = append(defaultTargets, defaultTargetsAAAA...) // nolint: gocritic // appendAssign labels := endpoint.Labels{"foo": "bar"} // Endpoints FROM SOURCE has NO targets sourceEndpoints := []*endpoint.Endpoint{ {DNSName: "foo", Targets: endpoint.Targets{}, Labels: labels}, {DNSName: "bar", Targets: endpoint.Targets{}, Labels: labels}, } // Expected endpoints SHOULD HAVE the default targets applied expectedEndpoints := []*endpoint.Endpoint{ {DNSName: "foo", Targets: defaultTargetsA, RecordType: "A", Labels: labels}, {DNSName: "bar", Targets: defaultTargetsA, RecordType: "A", Labels: labels}, {DNSName: "foo", Targets: defaultTargetsAAAA, RecordType: "AAAA", Labels: labels}, {DNSName: "bar", Targets: defaultTargetsAAAA, RecordType: "AAAA", Labels: labels}, {DNSName: "foo", Targets: defaultTargetsCName, RecordType: "CNAME", Labels: labels}, {DNSName: "bar", Targets: defaultTargetsCName, RecordType: "CNAME", Labels: labels}, } src := new(testutils.MockSource) src.On("Endpoints").Return(sourceEndpoints, nil) // Test with forceDefaultTargets=false (default behavior) source := NewMultiSource([]source.Source{src}, defaultTargets, false) endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, expectedEndpoints) src.AssertExpectations(t) }) t.Run("Defaults NOT applied when source targets exist", func(t *testing.T) { defaultTargets := []string{"127.0.0.1"} // Default target labels := endpoint.Labels{"foo": "bar"} // Endpoints FROM SOURCE HAS targets sourceEndpoints := []*endpoint.Endpoint{ {DNSName: "foo", Targets: endpoint.Targets{"8.8.8.8"}, Labels: labels}, {DNSName: "bar", Targets: endpoint.Targets{"8.8.4.4"}, Labels: labels}, } // Expected endpoints SHOULD MATCH the source endpoints (defaults ignored) expectedEndpoints := []*endpoint.Endpoint{ {DNSName: "foo", Targets: endpoint.Targets{"8.8.8.8"}, Labels: labels}, {DNSName: "bar", Targets: endpoint.Targets{"8.8.4.4"}, Labels: labels}, } src := new(testutils.MockSource) src.On("Endpoints").Return(sourceEndpoints, nil) // Test with forceDefaultTargets=false (default behavior) source := NewMultiSource([]source.Source{src}, defaultTargets, false) endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, expectedEndpoints) src.AssertExpectations(t) }) t.Run("Defaults forced when source targets exist and flag is set", func(t *testing.T) { defaultTargetsA := []string{"127.0.0.1", "127.0.0.2"} defaultTargetsAAAA := []string{"2001:db8::1"} defaultTargetsCName := []string{"foo.example.org"} defaultTargets := append(defaultTargetsA, defaultTargetsCName...) // nolint: gocritic // appendAssign defaultTargets = append(defaultTargets, defaultTargetsAAAA...) // nolint: gocritic // appendAssign labels := endpoint.Labels{"foo": "bar"} // Endpoints FROM SOURCE HAS targets sourceEndpoints := []*endpoint.Endpoint{ {DNSName: "foo", Targets: endpoint.Targets{"8.8.8.8"}, Labels: labels}, {DNSName: "bar", Targets: endpoint.Targets{"8.8.4.4"}, Labels: labels}, } // Expected endpoints SHOULD HAVE the default targets applied (old behavior) expectedEndpoints := []*endpoint.Endpoint{ {DNSName: "foo", Targets: defaultTargetsA, RecordType: "A", Labels: labels}, {DNSName: "bar", Targets: defaultTargetsA, RecordType: "A", Labels: labels}, {DNSName: "foo", Targets: defaultTargetsAAAA, RecordType: "AAAA", Labels: labels}, {DNSName: "bar", Targets: defaultTargetsAAAA, RecordType: "AAAA", Labels: labels}, {DNSName: "foo", Targets: defaultTargetsCName, RecordType: "CNAME", Labels: labels}, {DNSName: "bar", Targets: defaultTargetsCName, RecordType: "CNAME", Labels: labels}, } src := new(testutils.MockSource) src.On("Endpoints").Return(sourceEndpoints, nil) // Test with forceDefaultTargets=true (legacy behavior) source := NewMultiSource([]source.Source{src}, defaultTargets, true) endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, expectedEndpoints) src.AssertExpectations(t) }) t.Run("Defaults applied when source targets are empty and flag is set", func(t *testing.T) { defaultTargetsA := []string{"127.0.0.1", "127.0.0.2"} defaultTargetsAAAA := []string{"2001:db8::1"} defaultTargetsCName := []string{"foo.example.org"} defaultTargets := append(defaultTargetsA, defaultTargetsAAAA...) // nolint: gocritic // appendAssign defaultTargets = append(defaultTargets, defaultTargetsCName...) // nolint: gocritic // appendAssign labels := endpoint.Labels{"foo": "bar"} // Endpoints FROM SOURCE has NO targets sourceEndpoints := []*endpoint.Endpoint{ {DNSName: "empty-target-test", Targets: endpoint.Targets{}, Labels: labels}, } // Expected endpoints SHOULD HAVE the default targets applied expectedEndpoints := []*endpoint.Endpoint{ {DNSName: "empty-target-test", Targets: defaultTargetsA, RecordType: "A", Labels: labels}, {DNSName: "empty-target-test", Targets: defaultTargetsAAAA, RecordType: "AAAA", Labels: labels}, {DNSName: "empty-target-test", Targets: defaultTargetsCName, RecordType: "CNAME", Labels: labels}, } src := new(testutils.MockSource) src.On("Endpoints").Return(sourceEndpoints, nil) // Test with forceDefaultTargets=true source := NewMultiSource([]source.Source{src}, defaultTargets, true) endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, expectedEndpoints) src.AssertExpectations(t) }) } func TestMultiSource_AddEventHandler(t *testing.T) { tests := []struct { title string sources []source.Source times int }{ { title: "should not add event handler when sources are empty", sources: []source.Source{}, times: 0, }, { title: "should add event handler when sources not empty", sources: []source.Source{ testutils.NewMockSource(), testutils.NewMockSource(), testutils.NewMockSource(), }, times: 3, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { src := NewMultiSource(tt.sources, []string{}, true) src.AddEventHandler(t.Context(), func() {}) count := 0 for _, mockSource := range tt.sources { mSource := mockSource.(*testutils.MockSource) mSource.AssertNumberOfCalls(t, "AddEventHandler", 1) count += 1 } assert.Equal(t, tt.times, count) }) } } ================================================ FILE: source/wrappers/nat64source.go ================================================ /* Copyright 2024 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 wrappers import ( "context" "fmt" "net/netip" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source" ) var ( addrFromSlice = netip.AddrFromSlice ) // nat64Source is a Source that adds A endpoints for AAAA records including an NAT64 address. type nat64Source struct { source source.Source nat64Prefixes []netip.Prefix } // NewNAT64Source creates a new nat64Source wrapping the provided Source. func NewNAT64Source(source source.Source, nat64Prefixes []string) (source.Source, error) { parsedNAT64Prefixes := make([]netip.Prefix, 0) for _, prefix := range nat64Prefixes { pPrefix, err := netip.ParsePrefix(prefix) if err != nil { return nil, err } if pPrefix.Bits() != 96 { return nil, fmt.Errorf("NAT64 prefixes need to be /96 prefixes") } parsedNAT64Prefixes = append(parsedNAT64Prefixes, pPrefix) } return &nat64Source{source: source, nat64Prefixes: parsedNAT64Prefixes}, nil } // Endpoints collects endpoints from its wrapped source and returns them without duplicates. func (s *nat64Source) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { log.Debug("nat64Source: collecting endpoints and processing NAT64 translation") additionalEndpoints := []*endpoint.Endpoint{} endpoints, err := s.source.Endpoints(ctx) if err != nil { return nil, err } for _, ep := range endpoints { if ep.RecordType != endpoint.RecordTypeAAAA { continue } v4Targets := make([]string, 0) for _, target := range ep.Targets { ip, err := netip.ParseAddr(target) if err != nil { return nil, err } var sPrefix *netip.Prefix for _, cPrefix := range s.nat64Prefixes { if cPrefix.Contains(ip) { sPrefix = &cPrefix } } // If we do not have a NAT64 prefix, we skip this record. if sPrefix == nil { continue } ipBytes := ip.As16() v4AddrBytes := ipBytes[12:16] v4Addr, ok := addrFromSlice(v4AddrBytes) if !ok { return nil, fmt.Errorf("could not parse %v to IPv4 address", v4AddrBytes) } v4Targets = append(v4Targets, v4Addr.String()) } if len(v4Targets) == 0 { continue } v4EP := ep.DeepCopy() v4EP.Targets = v4Targets v4EP.RecordType = endpoint.RecordTypeA additionalEndpoints = append(additionalEndpoints, v4EP) } return append(endpoints, additionalEndpoints...), nil } func (s *nat64Source) AddEventHandler(ctx context.Context, handler func()) { log.Debug("nat64Source: adding event handler") s.source.AddEventHandler(ctx, handler) } ================================================ FILE: source/wrappers/nat64source_test.go ================================================ /* Copyright 2024 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 wrappers import ( "net/netip" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/source" ) // Validates that dedupSource is a Source var _ source.Source = &nat64Source{} func TestNAT64Source(t *testing.T) { t.Run("Endpoints", testNat64Source) } // testDedupEndpoints tests that duplicates from the wrapped source are removed. func testNat64Source(t *testing.T) { for _, tc := range []struct { title string endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { "single non-nat64 ipv6 endpoint returns one ipv6 endpoint", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8:1::1"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8:1::1"}}, }, }, { "single nat64 ipv6 endpoint returns one ipv4 endpoint and one ipv6 endpoint", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::192.0.2.42"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::192.0.2.42"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.0.2.42"}}, }, }, { "single nat64 ipv6 endpoint returns one ipv4 endpoint and one ipv6 endpoint", []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::c000:22a"}}, }, []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::c000:22a"}}, {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"192.0.2.42"}}, }, }, } { t.Run(tc.title, func(t *testing.T) { mockSource := new(testutils.MockSource) mockSource.On("Endpoints").Return(tc.endpoints, nil) // Create our object under test and get the endpoints. source, err := NewNAT64Source(mockSource, []string{"2001:DB8::/96"}) require.NoError(t, err) endpoints, err := source.Endpoints(t.Context()) require.NoError(t, err) // Validate returned endpoints against desired endpoints. validateEndpoints(t, endpoints, tc.expected) // Validate that the mock source was called. mockSource.AssertExpectations(t) }) } } func TestNat64Source_AddEventHandler(t *testing.T) { tests := []struct { title string input []string times int }{ { title: "should add event handler when prefixes are provided", input: []string{"2001:DB8::/96"}, times: 1, }, { title: "should add event handler when prefixes not provided", input: []string{}, times: 1, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { mockSource := testutils.NewMockSource() src, err := NewNAT64Source(mockSource, tt.input) require.NoError(t, err) src.AddEventHandler(t.Context(), func() {}) mockSource.AssertNumberOfCalls(t, "AddEventHandler", tt.times) }) } } func TestNewNAT64Source(t *testing.T) { type args struct { source source.Source nat64Prefixes []string } tests := []struct { name string args args want source.Source wantErr bool }{ { name: "empty NAT64 prefixes should succeed", args: args{ source: &testutils.MockSource{}, nat64Prefixes: []string{}, }, want: &nat64Source{source: &testutils.MockSource{}, nat64Prefixes: []netip.Prefix{}}, }, { name: "multiple valid NAT64 prefixes should succeed", args: args{ source: &testutils.MockSource{}, nat64Prefixes: []string{"2001:db8::/96", "64:ff9b::/96"}, }, want: &nat64Source{source: &testutils.MockSource{}, nat64Prefixes: []netip.Prefix{netip.MustParsePrefix("2001:db8::/96"), netip.MustParsePrefix("64:ff9b::/96")}}, }, { name: "invalid NAT64 prefix should fail", args: args{ source: &testutils.MockSource{}, nat64Prefixes: []string{"invalid-prefix"}, }, wantErr: true, }, { name: "NAT64 prefix with wrong mask length should fail", args: args{ source: &testutils.MockSource{}, nat64Prefixes: []string{"2001:db8::/64"}, }, wantErr: true, }, { name: "IPv4 address as NAT64 prefix should fail", args: args{ source: &testutils.MockSource{}, nat64Prefixes: []string{"192.0.2.0/24"}, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { src, err := NewNAT64Source(tt.args.source, tt.args.nat64Prefixes) if tt.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) } assert.Equal(t, tt.want, src) }) } } func TestNat64SourceEndpoints_VariousCases(t *testing.T) { tests := []struct { name string mockReturn []*endpoint.Endpoint mockError error setup func() asserts func([]*endpoint.Endpoint, error) }{ { name: "expect source error propagation", mockError: assert.AnError, asserts: func(eps []*endpoint.Endpoint, err error) { assert.Nil(t, eps) require.Error(t, err) require.ErrorIs(t, err, assert.AnError) }, }, { name: "skip nat64 processing for non-AAAA records", mockReturn: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"10.10.10.11"}}, }, asserts: func(eps []*endpoint.Endpoint, err error) { assert.NotNil(t, eps) assert.Len(t, eps, 1) require.NoError(t, err) }, }, { name: "target is not a valid IPv6 address", mockReturn: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"not-an-ip"}}, }, asserts: func(eps []*endpoint.Endpoint, err error) { assert.Nil(t, eps) require.Error(t, err) assert.EqualError(t, err, "ParseAddr(\"not-an-ip\"): unable to parse IP") }, }, { name: "addr from slice fails", mockReturn: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::192.0.2.42"}}, }, setup: func() { originalAddrFromSlice := addrFromSlice addrFromSlice = func([]byte) (netip.Addr, bool) { return netip.Addr{}, false } t.Cleanup(func() { addrFromSlice = originalAddrFromSlice }) }, asserts: func(eps []*endpoint.Endpoint, err error) { assert.Nil(t, eps) require.Error(t, err) assert.EqualError(t, err, "could not parse [192 0 2 42] to IPv4 address") }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if tc.setup != nil { tc.setup() } mockSource := new(testutils.MockSource) mockSource.On("Endpoints").Return(tc.mockReturn, tc.mockError) src, err := NewNAT64Source(mockSource, []string{"2001:db8::/96"}) require.NoError(t, err) eps, err := src.Endpoints(t.Context()) tc.asserts(eps, err) mockSource.AssertExpectations(t) }) } } ================================================ FILE: source/wrappers/post_processor.go ================================================ /* 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 wrappers import ( "context" "strings" "time" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source" "sigs.k8s.io/external-dns/source/annotations" ) type postProcessor struct { source source.Source cfg PostProcessorConfig } type PostProcessorConfig struct { ttl int64 provider string preferAlias bool isConfigured bool } type PostProcessorOption func(*PostProcessorConfig) func WithTTL(ttl time.Duration) PostProcessorOption { return func(cfg *PostProcessorConfig) { if int64(ttl.Seconds()) > 0 { cfg.isConfigured = true cfg.ttl = int64(ttl.Seconds()) } } } // WithPostProcessorProvider sets the provider used to retain provider-specific // properties on endpoints. Empty or whitespace-only values are ignored. func WithPostProcessorProvider(input string) PostProcessorOption { return func(cfg *PostProcessorConfig) { if p := strings.TrimSpace(input); p != "" { cfg.isConfigured = true cfg.provider = p } } } // WithPostProcessorPreferAlias enables setting alias=true on CNAME endpoints. // This signals to providers that support ALIAS records (like PowerDNS, AWS) // to create ALIAS records instead of CNAMEs. func WithPostProcessorPreferAlias(enabled bool) PostProcessorOption { return func(cfg *PostProcessorConfig) { cfg.preferAlias = enabled if enabled { cfg.isConfigured = true } } } func NewPostProcessor(source source.Source, opts ...PostProcessorOption) source.Source { cfg := PostProcessorConfig{} for _, opt := range opts { opt(&cfg) } return &postProcessor{source: source, cfg: cfg} } func (pp *postProcessor) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { endpoints, err := pp.source.Endpoints(ctx) if err != nil { return nil, err } if !pp.cfg.isConfigured { return endpoints, nil } for _, ep := range endpoints { if ep == nil { continue } ep.WithMinTTL(pp.cfg.ttl) ep.RetainProviderProperties(pp.cfg.provider) // Set alias annotation for CNAME records when preferAlias is enabled // Only set if not already explicitly configured at the source level if pp.cfg.preferAlias && ep.RecordType == endpoint.RecordTypeCNAME { if _, exists := ep.GetProviderSpecificProperty(annotations.AliasKey); !exists { ep.WithProviderSpecific("alias", "true") } } } return endpoints, nil } func (pp *postProcessor) AddEventHandler(ctx context.Context, handler func()) { log.Debug("postProcessor: adding event handler") pp.source.AddEventHandler(ctx, handler) } ================================================ FILE: source/wrappers/post_processor_test.go ================================================ /* 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 wrappers import ( "testing" "time" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" ) func TestWithPostProcessorProvider(t *testing.T) { tests := []struct { name string input string expectProvider string isConfigured bool }{ { name: "valid provider", input: "aws", expectProvider: "aws", isConfigured: true, }, { name: "empty string", input: "", isConfigured: false, }, { name: "whitespace only", input: " ", isConfigured: false, }, { name: "provider with surrounding whitespace", input: " aws ", expectProvider: "aws", isConfigured: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &PostProcessorConfig{} opt := WithPostProcessorProvider(tt.input) opt(cfg) require.Equal(t, tt.isConfigured, cfg.isConfigured, "isConfigured mismatch") require.Equal(t, tt.expectProvider, cfg.provider, "provider mismatch") }) } } func TestWithTTL(t *testing.T) { tests := []struct { name string ttlStr string expectErr bool expectTTL int64 isConfigured bool }{ { name: "valid 10m6s", ttlStr: "10m6s", expectErr: false, expectTTL: 606, isConfigured: true, }, { name: "valid 5m", ttlStr: "5m", expectTTL: 300, isConfigured: true, }, { name: "zero duration", ttlStr: "0s", expectTTL: 0, }, { name: "empty duration", ttlStr: "0s", expectTTL: 0, }, { name: "invalid duration", ttlStr: "notaduration", expectErr: true, expectTTL: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &PostProcessorConfig{} ttl, err := time.ParseDuration(tt.ttlStr) if tt.expectErr { require.Error(t, err, "should fail to parse duration string") return } require.NoError(t, err, "should parse duration string") opt := WithTTL(ttl) opt(cfg) require.Equal(t, tt.isConfigured, cfg.isConfigured, "isConfigured mismatch") require.Equal(t, tt.expectTTL, cfg.ttl, "ttl mismatch") }) } } func TestPostProcessorEndpointsWithTTL(t *testing.T) { tests := []struct { title string ttl string endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint expectErr bool }{ { title: "process endpoints with TTL set", ttl: "6s", endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo-1", "A", "1.2.3.4"), endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"), endpoint.NewEndpointWithTTL("foo-3", "A", 0, "1.2.3.6"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("foo-1", "A", 6, "1.2.3.4"), endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"), endpoint.NewEndpointWithTTL("foo-3", "A", 6, "1.2.3.6"), }, }, { title: "skip endpoints processing with TTL set to 0", ttl: "0s", endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo-1", "A", "1.2.3.4"), endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"), endpoint.NewEndpointWithTTL("foo-3", "A", 0, "1.2.3.6"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo-1", "A", "1.2.3.4"), endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"), endpoint.NewEndpointWithTTL("foo-3", "A", 0, "1.2.3.6"), }, }, { title: "skip endpoints processing for nill endpoint", ttl: "0s", endpoints: []*endpoint.Endpoint{ nil, endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"), }, expected: []*endpoint.Endpoint{ nil, endpoint.NewEndpointWithTTL("foo-2", "A", 60, "1.2.3.5"), }, }, { title: "endpoint foo-2 with TTL configured while foo-1 without TTL configured", ttl: "1s", endpoints: []*endpoint.Endpoint{ {DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.5"}}, {DNSName: "foo-2", Targets: endpoint.Targets{"1.2.3.6"}, RecordTTL: endpoint.TTL(0)}, }, expected: []*endpoint.Endpoint{ {DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.5"}, RecordTTL: endpoint.TTL(1)}, {DNSName: "foo-2", Targets: endpoint.Targets{"1.2.3.6"}, RecordTTL: endpoint.TTL(1)}, }, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { ms := new(testutils.MockSource) ms.On("Endpoints").Return(tt.endpoints, nil) ttl, _ := time.ParseDuration(tt.ttl) src := NewPostProcessor(ms, WithTTL(ttl)) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } func TestPostProcessorEndpointsWithPostProcessorProviderFilter(t *testing.T) { tests := []struct { title string provider string endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { title: "no provider configured, properties untouched", provider: "", endpoints: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "coredns/group", Value: "my-group"}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "coredns/group", Value: "my-group"}, }, }, }, }, { title: "provider configured, all properties match", provider: "aws", endpoints: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "aws/weight", Value: "10"}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "aws/weight", Value: "10"}, }, }, }, }, { title: "provider configured, mixed properties, only provider retained", provider: "aws", endpoints: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "coredns/group", Value: "my-group"}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/evaluate-target-health", Value: "true"}, }, }, }, }, { title: "provider configured, no matching properties, empty result", provider: "aws", endpoints: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "coredns/group", Value: "my-group"}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, }, }, }, { title: "provider agnostic properties without prefix are retained", provider: "aws", endpoints: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "alias", Value: "true"}, {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "coredns/group", Value: "my-group"}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "alias", Value: "true"}, {Name: "aws/evaluate-target-health", Value: "true"}, }, }, }, }, { title: "cloudflare retains all properties regardless of prefix", provider: "cloudflare", endpoints: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"}, {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "alias", Value: "false"}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "alias", Value: "false"}, {Name: "aws/evaluate-target-health", Value: "true"}, {Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"}, }, }, }, }, { title: "cloudflare properties are sorted", provider: "cloudflare", endpoints: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"}, {Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true"}, }, }, }, expected: []*endpoint.Endpoint{ { DNSName: "foo-1", Targets: endpoint.Targets{"1.2.3.4"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", Value: "true"}, {Name: "external-dns.alpha.kubernetes.io/cloudflare-tags", Value: "tag1"}, }, }, }, }, { title: "nil endpoint is skipped", provider: "aws", endpoints: []*endpoint.Endpoint{ nil, { DNSName: "foo-2", Targets: endpoint.Targets{"1.2.3.5"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, {Name: "coredns/group", Value: "my-group"}, }, }, }, expected: []*endpoint.Endpoint{ nil, { DNSName: "foo-2", Targets: endpoint.Targets{"1.2.3.5"}, ProviderSpecific: endpoint.ProviderSpecific{ {Name: "aws/weight", Value: "10"}, }, }, }, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { ms := new(testutils.MockSource) ms.On("Endpoints").Return(tt.endpoints, nil) src := NewPostProcessor(ms, WithPostProcessorProvider(tt.provider)) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } func TestPostProcessor_AddEventHandler(t *testing.T) { tests := []struct { title string input []string times int }{ { title: "should add event handler", times: 1, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { mockSource := testutils.NewMockSource() src := NewPostProcessor(mockSource) src.AddEventHandler(t.Context(), func() {}) mockSource.AssertNumberOfCalls(t, "AddEventHandler", tt.times) }) } } func TestPostProcessorEndpointsWithPreferAlias(t *testing.T) { tests := []struct { title string preferAlias bool endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { title: "CNAME records get alias annotation when preferAlias is enabled", preferAlias: true, endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeCNAME, "target.example.com"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeA, "1.2.3.4"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeCNAME, "target.example.com").WithProviderSpecific("alias", "true"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeA, "1.2.3.4"), }, }, { title: "CNAME records remain unchanged when preferAlias is disabled", preferAlias: false, endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeCNAME, "target.example.com"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeCNAME, "target.example.com"), }, }, { title: "only CNAME records are affected, A records are unchanged", preferAlias: true, endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("a.example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("aaaa.example.com", endpoint.RecordTypeAAAA, "::1"), endpoint.NewEndpoint("cname.example.com", endpoint.RecordTypeCNAME, "target.example.com"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("a.example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("aaaa.example.com", endpoint.RecordTypeAAAA, "::1"), endpoint.NewEndpoint("cname.example.com", endpoint.RecordTypeCNAME, "target.example.com").WithProviderSpecific("alias", "true"), }, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { ms := new(testutils.MockSource) ms.On("Endpoints").Return(tt.endpoints, nil) src := NewPostProcessor(ms, WithPostProcessorPreferAlias(tt.preferAlias)) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err) validateEndpoints(t, endpoints, tt.expected) }) } } ================================================ FILE: source/wrappers/source_test.go ================================================ /* 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 wrappers import ( "reflect" "sort" "testing" "sigs.k8s.io/external-dns/endpoint" ) func sortEndpoints(endpoints []*endpoint.Endpoint) { for _, ep := range endpoints { if ep != nil { ep.Targets = endpoint.NewTargets(ep.Targets...) } } sort.Slice(endpoints, func(i, k int) bool { // Sort by DNSName, RecordType, and Targets ei, ek := endpoints[i], endpoints[k] if ei == nil || ek == nil { return true } if ei.DNSName != ek.DNSName { return ei.DNSName < ek.DNSName } if ei.RecordType != ek.RecordType { return ei.RecordType < ek.RecordType } // Targets are sorted ahead of time. for j, ti := range ei.Targets { if j >= len(ek.Targets) { return true } if tk := ek.Targets[j]; ti != tk { return ti < tk } } return false }) } func validateEndpoints(t *testing.T, endpoints, expected []*endpoint.Endpoint) { t.Helper() if len(endpoints) != len(expected) { t.Fatalf("expected %d endpoints, got %d", len(expected), len(endpoints)) } // Make sure endpoints are sorted - validateEndpoint() depends on it. sortEndpoints(endpoints) sortEndpoints(expected) for i := range endpoints { validateEndpoint(t, endpoints[i], expected[i]) } } func validateEndpoint(t *testing.T, endpoint, expected *endpoint.Endpoint) { t.Helper() if endpoint == nil && expected == nil { return } if endpoint.DNSName != expected.DNSName { t.Errorf("DNSName expected %q, got %q", expected.DNSName, endpoint.DNSName) } if !endpoint.Targets.Same(expected.Targets) { t.Errorf("Targets expected %q, got %q", expected.Targets, endpoint.Targets) } if endpoint.RecordTTL != expected.RecordTTL { t.Errorf("RecordTTL expected %v, got %v", expected.RecordTTL, endpoint.RecordTTL) } // if a non-empty record type is expected, check that it matches. if endpoint.RecordType != expected.RecordType { t.Errorf("RecordType expected %q, got %q", expected.RecordType, endpoint.RecordType) } // if non-empty labels are expected, check that they match. if expected.Labels != nil && !reflect.DeepEqual(endpoint.Labels, expected.Labels) { t.Errorf("Labels expected %s, got %s", expected.Labels, endpoint.Labels) } if (len(expected.ProviderSpecific) != 0 || len(endpoint.ProviderSpecific) != 0) && !reflect.DeepEqual(endpoint.ProviderSpecific, expected.ProviderSpecific) { t.Errorf("ProviderSpecific expected %s, got %s", expected.ProviderSpecific, endpoint.ProviderSpecific) } if endpoint.SetIdentifier != expected.SetIdentifier { t.Errorf("SetIdentifier expected %q, got %q", expected.SetIdentifier, endpoint.SetIdentifier) } } ================================================ FILE: source/wrappers/targetfiltersource.go ================================================ /* Copyright 2017 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 wrappers import ( "context" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source" ) // targetFilterSource is a Source that removes endpoints matching the target filter from its wrapped source. type targetFilterSource struct { source source.Source targetFilter endpoint.TargetFilterInterface } // NewTargetFilterSource creates a new targetFilterSource wrapping the provided Source. func NewTargetFilterSource(source source.Source, targetFilter endpoint.TargetFilterInterface) source.Source { return &targetFilterSource{source: source, targetFilter: targetFilter} } // Endpoints collects endpoints from its wrapped source and returns // them without targets matching the target filter. func (ms *targetFilterSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { log.Debug("targetFilterSource: collecting endpoints from wrapped source and applying target filter") endpoints, err := ms.source.Endpoints(ctx) if err != nil { return nil, err } if !ms.targetFilter.IsEnabled() { return endpoints, nil } result := make([]*endpoint.Endpoint, 0, len(endpoints)) for _, ep := range endpoints { filteredTargets := make([]string, 0, len(ep.Targets)) for _, t := range ep.Targets { if ms.targetFilter.Match(t) { filteredTargets = append(filteredTargets, t) } } // If all targets are filtered out, skip the endpoint. if len(filteredTargets) == 0 { log.WithField("endpoint", ep).Debugf("Skipping endpoint because all targets were filtered out") continue } ep.Targets = filteredTargets result = append(result, ep) } return result, nil } func (ms *targetFilterSource) AddEventHandler(ctx context.Context, handler func()) { log.Debug("targetFilterSource: adding event handler") ms.source.AddEventHandler(ctx, handler) } ================================================ FILE: source/wrappers/targetfiltersource_test.go ================================================ /* Copyright 2017 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 wrappers import ( "testing" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/source" "sigs.k8s.io/external-dns/endpoint" ) type mockTargetNetFilter struct { targets map[string]bool } func NewMockTargetNetFilter(targets []string) endpoint.TargetFilterInterface { targetMap := make(map[string]bool) for _, target := range targets { targetMap[target] = true } return &mockTargetNetFilter{targets: targetMap} } func (m *mockTargetNetFilter) Match(target string) bool { return m.targets[target] } func (m *mockTargetNetFilter) IsEnabled() bool { return true } func TestEchoSourceReturnGivenSources(t *testing.T) { startEndpoints := []*endpoint.Endpoint{{ DNSName: "foo.bar.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}, RecordTTL: endpoint.TTL(300), Labels: endpoint.Labels{}, }} e := testutils.NewMockSource(startEndpoints...) endpoints, err := e.Endpoints(t.Context()) if err != nil { t.Errorf("Expected no error but got %s", err.Error()) } for i, ep := range endpoints { if ep != startEndpoints[i] { t.Errorf("Expected %s but got %s", startEndpoints[i], ep) } } } func TestTargetFilterSource(t *testing.T) { t.Parallel() t.Run("Interface", TestTargetFilterSourceImplementsSource) t.Run("Endpoints", TestTargetFilterSourceEndpoints) } // TestTargetFilterSourceImplementsSource tests that targetFilterSource is a valid Source. func TestTargetFilterSourceImplementsSource(_ *testing.T) { var _ source.Source = &targetFilterSource{} } func TestTargetFilterSourceEndpoints(t *testing.T) { t.Parallel() tests := []struct { title string filters endpoint.TargetFilterInterface endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { title: "filter exclusion all", filters: NewMockTargetNetFilter([]string{}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.5"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.6"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.3.4.5"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.4.4.5")}, expected: []*endpoint.Endpoint{}, }, { title: "filter exclude internal net", filters: NewMockTargetNetFilter([]string{"8.8.8.8"}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.0.1"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "8.8.8.8")}, expected: []*endpoint.Endpoint{endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "8.8.8.8")}, }, { title: "filter only internal", filters: NewMockTargetNetFilter([]string{"10.0.0.1"}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.0.1"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "8.8.8.8")}, expected: []*endpoint.Endpoint{endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.0.1")}, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { t.Parallel() echo := testutils.NewMockSource(tt.endpoints...) src := NewTargetFilterSource(echo, tt.filters) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err, "failed to get Endpoints") validateEndpoints(t, endpoints, tt.expected) }) } } func TestTargetFilterConcreteTargetFilter(t *testing.T) { tests := []struct { title string filters endpoint.TargetFilterInterface endpoints []*endpoint.Endpoint expected []*endpoint.Endpoint }{ { title: "should skip filtering if no filters are set", filters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.5"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.6"), }, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.5"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "1.2.3.6"), }, }, { title: "should include all targets when filters are not correctly set", filters: endpoint.NewTargetNetFilterWithExclusions([]string{"8.8.8.8"}, []string{}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.0.1"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "8.8.8.8")}, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.0.1"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "8.8.8.8"), }, }, { title: "should include internal when include filter is set", filters: endpoint.NewTargetNetFilterWithExclusions([]string{"10.0.0.0/8"}, []string{}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.0.1"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "49.13.41.161")}, expected: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.0.1"), }, }, { title: "exclude internal keep public ips", filters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{"10.0.0.0/8"}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.178.43"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.1.101"), endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "49.13.41.161")}, expected: []*endpoint.Endpoint{endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "49.13.41.161")}, }, { title: "should not exclude ipv6 when excluding ipv4", filters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{"10.0.0.0/8"}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.178.43"), endpoint.NewEndpoint("foo", endpoint.RecordTypeAAAA, "2a01:asdf:asdf:asdf::1"), }, expected: []*endpoint.Endpoint{endpoint.NewEndpoint("foo", endpoint.RecordTypeAAAA, "2a01:asdf:asdf:asdf::1")}, }, { title: "should not include ipv6 when including ipv4", filters: endpoint.NewTargetNetFilterWithExclusions([]string{"10.0.0.0/8"}, []string{}), endpoints: []*endpoint.Endpoint{ endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.178.43"), endpoint.NewEndpoint("foo", endpoint.RecordTypeAAAA, "2a01:asdf:asdf:asdf::1"), }, expected: []*endpoint.Endpoint{endpoint.NewEndpoint("foo", endpoint.RecordTypeA, "10.0.178.43")}, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { echo := testutils.NewMockSource(tt.endpoints...) src := NewTargetFilterSource(echo, tt.filters) endpoints, err := src.Endpoints(t.Context()) require.NoError(t, err, "failed to get Endpoints") validateEndpoints(t, endpoints, tt.expected) }) } } func TestTargetFilterSource_AddEventHandler(t *testing.T) { tests := []struct { title string filters endpoint.TargetFilterInterface times int }{ { title: "should add event handler if target filter is enabled", filters: endpoint.NewTargetNetFilterWithExclusions([]string{"10.0.0.0/8"}, []string{}), times: 1, }, { title: "should add event handler if target filter is disabled", filters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{}), times: 1, }, } for _, tt := range tests { t.Run(tt.title, func(t *testing.T) { m := testutils.NewMockSource() src := NewTargetFilterSource(m, tt.filters) src.AddEventHandler(t.Context(), func() {}) m.AssertNumberOfCalls(t, "AddEventHandler", tt.times) }) } } ================================================ FILE: source/wrappers/types.go ================================================ /* 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 wrappers import ( "fmt" "time" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source" ) type Config struct { defaultTargets []string forceDefaultTargets bool provider string nat64Networks []string targetNetFilter []string excludeTargetNets []string minTTL time.Duration preferAlias bool sourceWrappers map[string]bool // map of source wrappers, e.g. "targetfilter", "nat64" } func NewConfig(opts ...Option) *Config { o := &Config{} for _, opt := range opts { opt(o) } return o } type Option func(config *Config) func WithDefaultTargets(input []string) Option { return func(o *Config) { o.defaultTargets = input } } func WithForceDefaultTargets(input bool) Option { return func(o *Config) { o.forceDefaultTargets = input } } func WithNAT64Networks(input []string) Option { return func(o *Config) { o.nat64Networks = input } } func WithTargetNetFilter(input []string) Option { return func(o *Config) { o.targetNetFilter = input } } func WithExcludeTargetNets(input []string) Option { return func(o *Config) { o.excludeTargetNets = input } } func WithMinTTL(ttl time.Duration) Option { return func(o *Config) { o.minTTL = ttl } } // WithProvider sets the DNS provider name, used to filter provider-specific // endpoint properties to only those belonging to the configured provider. func WithProvider(input string) Option { return func(o *Config) { o.provider = input } } func WithPreferAlias(enabled bool) Option { return func(o *Config) { o.preferAlias = enabled } } // addSourceWrapper registers a source wrapper by name in the Config. // It initializes the sourceWrappers map if it is nil. func (o *Config) addSourceWrapper(name string) { if o.sourceWrappers == nil { o.sourceWrappers = make(map[string]bool) } o.sourceWrappers[name] = true } // isSourceWrapperInstrumented returns whether a source wrapper is enabled or not. func (o *Config) isSourceWrapperInstrumented(name string) bool { if o.sourceWrappers == nil { return false } _, ok := o.sourceWrappers[name] return ok } // WrapSources combines multiple sources into a single source, // applies optional NAT64 and target network filtering wrappers, and sets a minimum TTL. // It registers each applied wrapper in the Config for instrumentation. func WrapSources( sources []source.Source, opts *Config, ) (source.Source, error) { combinedSource := NewDedupSource(NewMultiSource(sources, opts.defaultTargets, opts.forceDefaultTargets)) opts.addSourceWrapper("dedup") if len(opts.nat64Networks) > 0 { var err error combinedSource, err = NewNAT64Source(combinedSource, opts.nat64Networks) if err != nil { return nil, fmt.Errorf("failed to create NAT64 source wrapper: %w", err) } opts.addSourceWrapper("nat64") } targetFilter := endpoint.NewTargetNetFilterWithExclusions(opts.targetNetFilter, opts.excludeTargetNets) if targetFilter.IsEnabled() { combinedSource = NewTargetFilterSource(combinedSource, targetFilter) opts.addSourceWrapper("target-filter") } combinedSource = NewPostProcessor(combinedSource, WithTTL(opts.minTTL), WithPostProcessorPreferAlias(opts.preferAlias), WithPostProcessorProvider(opts.provider)) opts.addSourceWrapper("post-processor") return combinedSource, nil } ================================================ FILE: source/wrappers/types_test.go ================================================ /* 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 wrappers import ( "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestBuildSourceWithWrappers(t *testing.T) { tests := []struct { name string cfg *Config asserts func(*testing.T, *Config) }{ { name: "configuration with target filter wrapper", cfg: NewConfig( WithTargetNetFilter([]string{"10.0.0.0/8"}), ), asserts: func(t *testing.T, cfg *Config) { assert.True(t, cfg.isSourceWrapperInstrumented("target-filter")) }, }, { name: "configuration with nat64 networks", cfg: NewConfig( WithNAT64Networks([]string{"2001:db8::/96"}), ), asserts: func(t *testing.T, cfg *Config) { assert.True(t, cfg.isSourceWrapperInstrumented("nat64")) }, }, { name: "default configuration", cfg: NewConfig(), asserts: func(t *testing.T, cfg *Config) { assert.True(t, cfg.isSourceWrapperInstrumented("dedup")) assert.False(t, cfg.isSourceWrapperInstrumented("nat64")) assert.False(t, cfg.isSourceWrapperInstrumented("target-filter")) }, }, { name: "with TTL and NAT64", cfg: NewConfig( WithMinTTL(300), WithNAT64Networks([]string{"2001:db8::/96"}), ), asserts: func(t *testing.T, cfg *Config) { assert.True(t, cfg.isSourceWrapperInstrumented("dedup")) assert.True(t, cfg.isSourceWrapperInstrumented("nat64")) assert.True(t, cfg.isSourceWrapperInstrumented("post-processor")) assert.False(t, cfg.isSourceWrapperInstrumented("target-filter")) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := WrapSources(nil, tt.cfg) require.NoError(t, err) tt.asserts(t, tt.cfg) }) } } func TestWrapSources_NAT64Error(t *testing.T) { cfg := NewConfig(WithNAT64Networks([]string{"badnet"})) src, err := WrapSources(nil, cfg) assert.Nil(t, src) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to create NAT64 source wrapper") } func TestWithDefaultTargets(t *testing.T) { cfg := &Config{} opt := WithDefaultTargets([]string{"1.2.3.4"}) opt(cfg) assert.Equal(t, []string{"1.2.3.4"}, cfg.defaultTargets) } func TestWithForceDefaultTargets(t *testing.T) { cfg := &Config{} opt := WithForceDefaultTargets(true) opt(cfg) assert.True(t, cfg.forceDefaultTargets) } func TestWithNAT64Networks(t *testing.T) { cfg := &Config{} opt := WithNAT64Networks([]string{"2001:db8::/96"}) opt(cfg) assert.Equal(t, []string{"2001:db8::/96"}, cfg.nat64Networks) } func TestWithTargetNetFilter(t *testing.T) { cfg := &Config{} opt := WithTargetNetFilter([]string{"10.0.0.0/8"}) opt(cfg) assert.Equal(t, []string{"10.0.0.0/8"}, cfg.targetNetFilter) } func TestWithExcludeTargetNets(t *testing.T) { cfg := &Config{} opt := WithExcludeTargetNets([]string{"192.168.0.0/16"}) opt(cfg) assert.Equal(t, []string{"192.168.0.0/16"}, cfg.excludeTargetNets) } func TestWithMinTTL(t *testing.T) { cfg := &Config{} opt := WithMinTTL(300 * time.Second) opt(cfg) assert.Equal(t, 300*time.Second, cfg.minTTL) } func TestAddSourceWrapperAndIsSourceWrapperInstrumented(t *testing.T) { cfg := &Config{} assert.False(t, cfg.isSourceWrapperInstrumented("dedup")) cfg.addSourceWrapper("dedup") assert.True(t, cfg.isSourceWrapperInstrumented("dedup")) cfg.addSourceWrapper("nat64") assert.True(t, cfg.isSourceWrapperInstrumented("nat64")) assert.False(t, cfg.isSourceWrapperInstrumented("target-filter")) } ================================================ FILE: tests/integration/OWNERS ================================================ # See the OWNERS docs at https://go.k8s.io/owners labels: - tests-integration ================================================ FILE: tests/integration/scenarios/tests.yaml ================================================ # Integration Test Scenarios # # Schema Summary: # | Field | Type | Description | # |----------------------------------------|----------|------------------------------------------| # | name | string | Test scenario name | # | config.sources | []string | Sources to create: ingress, service | # | config.defaultTargets | []string | --default-targets flag values | # | config.forceDefaultTargets | bool | --force-default-targets flag | # | config.targetNetFilter | []string | --target-net-filter flag values | # | config.serviceTypeFilter | []string | --service-type-filter flag values | # | resources | []object | K8s resources with optional dependencies | # | resources[].resource | object | K8s resource (Ingress, Service, etc.) | # | resources[].dependencies | object | Auto-generated dependent resources | # | resources[].dependencies.pods.replicas | int | Number of pods to generate | # | expected | []object | Expected endpoints | # TODO: # 1. Support to Endpoint.ResourceLabelKey # 2. Support for Endpoint.RefObject # 3. Support for Endpoint.ProviderSpecific scenarios: - name: headless-service-with-pods description: > Test that a headless Service with associated Pods creates the correct DNS A records for each Pod IP. config: sources: ["service"] targetNetFilter: ["10.0.0.1/32", "10.0.0.2/32"] serviceTypeFilter: ["ClusterIP"] resources: - resource: apiVersion: v1 kind: Service metadata: name: headless-svc namespace: default labels: app: myapp annotations: external-dns.alpha.kubernetes.io/hostname: headless.example.com spec: type: ClusterIP clusterIP: None selector: app: myapp dependencies: pods: replicas: 3 expected: - dnsName: headless.example.com targets: ["10.0.0.1", "10.0.0.2"] recordType: A - dnsName: headless-svc-0.headless.example.com targets: ["10.0.0.1"] recordType: A - dnsName: headless-svc-1.headless.example.com targets: ["10.0.0.2"] recordType: A - name: service-loadbalancer-with-ip description: > Test that a Service of type LoadBalancer with an assigned IP creates the correct DNS A record. config: sources: ["service"] resources: - resource: apiVersion: v1 kind: Service metadata: name: test-service namespace: default annotations: external-dns.alpha.kubernetes.io/hostname: svc.example.com spec: selector: app.kubernetes.io/name: MyApp type: LoadBalancer status: loadBalancer: ingress: - ip: 1.2.3.4 expected: - dnsName: svc.example.com targets: ["1.2.3.4"] recordType: A - name: known-limitation-cross-source-dedup-not-applied description: > Documents a known limitation: when multiple ingresses share the same hostname, ingressSource.Endpoints() merges their targets via MergeEndpoints() before the dedupSource ever sees them. The resulting combined ingress endpoint (["1.2.3.4","203.0.113.10"]) has different Targets.String() than the service endpoint (["1.2.3.4"]), so the dedupSource keeps both — producing two A records for the same hostname. Ideally the service endpoint would be absorbed into the ingress one, but that would require cross-source target merging which does not exist today. See the "service-and-ingress-same-hostname-and-ip-dedup" scenario for a case where deduplication does work correctly. config: sources: ["service", "ingress"] resources: - resource: apiVersion: v1 kind: Service metadata: name: test-service namespace: default annotations: external-dns.alpha.kubernetes.io/hostname: example.local spec: selector: app.kubernetes.io/name: MyApp type: LoadBalancer status: loadBalancer: ingress: - ip: 1.2.3.4 - resource: apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: test-ingress namespace: default annotations: kubernetes.io/ingress.class: "nginx" spec: rules: - host: example.local http: paths: - path: / pathType: Prefix backend: service: name: my-service port: number: 80 status: loadBalancer: ingress: - ip: 203.0.113.10 - resource: apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: ingress-with-same-host-and-status-as-service namespace: kube-system annotations: kubernetes.io/ingress.class: "nginx" spec: rules: - host: example.local http: paths: - path: / pathType: Prefix backend: service: name: my-service port: number: 80 status: loadBalancer: ingress: - ip: 1.2.3.4 expected: # The ingress source merges all ingresses with the same hostname via MergeEndpoints(), # so both ingresses (203.0.113.10 and 1.2.3.4) produce one combined endpoint. - dnsName: example.local targets: ["1.2.3.4", "203.0.113.10"] recordType: A # The service source produces a separate endpoint for the same hostname. - dnsName: example.local targets: ["1.2.3.4"] recordType: A - name: service-and-ingress-same-hostname-and-ip-dedup description: > Test that dedupSource correctly removes an exact duplicate endpoint. Both the Service and the Ingress resolve example.local to the same IP (1.2.3.4), so each source emits an identical endpoint (RecordType=A, DNSName=example.local, Targets=["1.2.3.4"]). The dedupSource key is RecordType+DNSName+SetIdentifier+Targets.String(), which matches, so the second endpoint is dropped and only one A record survives. config: sources: ["service", "ingress"] resources: - resource: apiVersion: v1 kind: Service metadata: name: test-service namespace: default annotations: external-dns.alpha.kubernetes.io/hostname: example.local spec: selector: app.kubernetes.io/name: MyApp type: LoadBalancer status: loadBalancer: ingress: - ip: 1.2.3.4 - resource: apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: test-ingress namespace: default annotations: kubernetes.io/ingress.class: "nginx" spec: rules: - host: example.local http: paths: - path: / pathType: Prefix backend: service: name: my-service port: number: 80 status: loadBalancer: ingress: - ip: 1.2.3.4 expected: - dnsName: example.local targets: ["1.2.3.4"] recordType: A ================================================ FILE: tests/integration/source_test.go ================================================ /* Copyright 2026 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 integration import ( _ "embed" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sigs.k8s.io/external-dns/tests/integration/toolkit" ) var ( //go:embed scenarios/tests.yaml testsYAML []byte ) func mustLoadScenarios(t *testing.T) *toolkit.TestScenarios { t.Helper() testScenarios, err := toolkit.LoadScenarios(testsYAML) require.NoError(t, err, "failed to load scenarios") require.NotEmpty(t, testScenarios.Scenarios, "no scenarios found") return testScenarios } func TestParseResources(t *testing.T) { scenarios := mustLoadScenarios(t) for _, scenario := range scenarios.Scenarios { t.Run(scenario.Name, func(t *testing.T) { parsed, err := toolkit.ParseResources(scenario.Resources) require.NoError(t, err, "failed to parse resources") totalParsed := len(parsed.Services) + len(parsed.Ingresses) + len(parsed.Pods) + len(parsed.EndpointSlices) // Pods and EndpointSlices may be auto-generated from dependencies, so count // only the explicitly declared resources when checking nothing was silently dropped. explicitResources := 0 for _, r := range scenario.Resources { explicitResources++ if r.Dependencies != nil && r.Dependencies.Pods != nil { // Each Service with pod dependencies generates Pods + one EndpointSlice. explicitResources += r.Dependencies.Pods.Replicas + 1 } } assert.Equal(t, explicitResources, totalParsed, "parsed resource count does not match declared resources") }) } } func TestSourceIntegration(t *testing.T) { scenarios := mustLoadScenarios(t) for _, scenario := range scenarios.Scenarios { t.Run(scenario.Name, func(t *testing.T) { ctx := t.Context() client, err := toolkit.LoadResources(ctx, scenario) require.NoError(t, err, "failed to populate resources") // Create wrapped source wrappedSource, err := toolkit.CreateWrappedSource(ctx, client, scenario.Config) require.NoError(t, err, "failed to create wrapped source") // Get endpoints endpoints, err := wrappedSource.Endpoints(ctx) require.NoError(t, err) toolkit.ValidateEndpoints(t, endpoints, scenario.Expected) }) } } ================================================ FILE: tests/integration/toolkit/mocks.go ================================================ /* Copyright 2026 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 toolkit import ( "fmt" openshift "github.com/openshift/client-go/route/clientset/versioned" "github.com/stretchr/testify/mock" istioclient "istio.io/client-go/pkg/clientset/versioned" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/rest" gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" ) // MockClientGenerator implements source.ClientGenerator for testing. type MockClientGenerator struct { mock.Mock } func (m *MockClientGenerator) RESTConfig() (*rest.Config, error) { return nil, fmt.Errorf("RESTConfig: not implemented") } func (m *MockClientGenerator) KubeClient() (kubernetes.Interface, error) { args := m.Called() if args.Error(1) != nil { return nil, args.Error(1) } return args.Get(0).(kubernetes.Interface), nil } func (m *MockClientGenerator) GatewayClient() (gateway.Interface, error) { args := m.Called() if args.Error(1) != nil { return nil, args.Error(1) } return args.Get(0).(gateway.Interface), nil } func (m *MockClientGenerator) IstioClient() (istioclient.Interface, error) { args := m.Called() if args.Error(1) != nil { return nil, args.Error(1) } return args.Get(0).(istioclient.Interface), nil } func (m *MockClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) { args := m.Called() if args.Error(1) != nil { return nil, args.Error(1) } return args.Get(0).(dynamic.Interface), nil } func (m *MockClientGenerator) OpenShiftClient() (openshift.Interface, error) { args := m.Called() if args.Error(1) != nil { return nil, args.Error(1) } return args.Get(0).(openshift.Interface), nil } // newMockClientGenerator creates a MockClientGenerator that returns the provided fake client. func newMockClientGenerator(client *fake.Clientset) *MockClientGenerator { m := new(MockClientGenerator) m.On("KubeClient").Return(client, nil) return m } ================================================ FILE: tests/integration/toolkit/models.go ================================================ /* Copyright 2026 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 toolkit import ( corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" networkingv1 "k8s.io/api/networking/v1" k8sruntime "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/external-dns/endpoint" ) // TestScenarios represents the root structure of the YAML file. type TestScenarios struct { Scenarios []Scenario `json:"scenarios"` } // Scenario represents a single test scenario. type Scenario struct { Name string `json:"name"` Description string `json:"description"` Config ScenarioConfig `json:"config"` Resources []ResourceWithDependencies `json:"resources"` Expected []*endpoint.Endpoint `json:"expected"` } // ResourceWithDependencies wraps a K8s resource with optional dependencies. type ResourceWithDependencies struct { Resource k8sruntime.RawExtension `json:"resource"` Dependencies *ResourceDependencies `json:"dependencies,omitempty"` } // ResourceDependencies defines auto-generated dependent resources. type ResourceDependencies struct { Pods *PodDependencies `json:"pods,omitempty"` } // PodDependencies defines how to generate Pods and EndpointSlices for a Service. type PodDependencies struct { Replicas int `json:"replicas"` } // ScenarioConfig holds the wrapper configuration for a scenario. type ScenarioConfig struct { Sources []string `json:"sources"` DefaultTargets []string `json:"defaultTargets"` ForceDefaultTargets bool `json:"forceDefaultTargets"` TargetNetFilter []string `json:"targetNetFilter"` ServiceTypeFilter []string `json:"serviceTypeFilter"` } // ParsedResources holds the parsed Kubernetes resources from a scenario. type ParsedResources struct { Ingresses []*networkingv1.Ingress Services []*corev1.Service EndpointSlices []*discoveryv1.EndpointSlice Pods []*corev1.Pod } ================================================ FILE: tests/integration/toolkit/toolkit.go ================================================ /* Copyright 2026 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 toolkit import ( "context" "fmt" "maps" "reflect" "sort" "testing" corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/client-go/kubernetes/fake" "sigs.k8s.io/yaml" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source" "sigs.k8s.io/external-dns/source/wrappers" ) // Initialized at package load; safe for concurrent use after that. var ( scheme = func() *runtime.Scheme { s := runtime.NewScheme() utilruntime.Must(corev1.AddToScheme(s)) utilruntime.Must(discoveryv1.AddToScheme(s)) utilruntime.Must(networkingv1.AddToScheme(s)) return s }() decoder = serializer.NewCodecFactory(scheme).UniversalDeserializer() ) // LoadScenarios loads test scenarios from the embedded YAML data. func LoadScenarios(data []byte) (*TestScenarios, error) { var scenarios TestScenarios if err := yaml.Unmarshal(data, &scenarios); err != nil { return nil, err } // Validate scenarios for i, s := range scenarios.Scenarios { if s.Name == "" { return nil, fmt.Errorf("scenario %d is missing required field: name", i) } if s.Description == "" { return nil, fmt.Errorf("scenario %d (%q) is missing required field: description", i, s.Name) } if len(s.Config.Sources) == 0 { return nil, fmt.Errorf("scenario %d (%q) is missing required field: config.sources", i, s.Name) } } return &scenarios, nil } // ParseResources parses the raw resources from a scenario into typed objects. func ParseResources(resources []ResourceWithDependencies) (*ParsedResources, error) { parsed := &ParsedResources{} for _, item := range resources { obj, _, err := decoder.Decode(item.Resource.Raw, nil, nil) if err != nil { return nil, fmt.Errorf("failed to decode resource: %w", err) } switch res := obj.(type) { case *corev1.Pod: parsed.Pods = append(parsed.Pods, res) case *corev1.Service: parsed.Services = append(parsed.Services, res) // Auto-generate Pods and EndpointSlice if dependencies are specified if item.Dependencies != nil && item.Dependencies.Pods != nil { pods, endpointSlice := generatePodsAndEndpointSlice(res, item.Dependencies.Pods) parsed.Pods = append(parsed.Pods, pods...) parsed.EndpointSlices = append(parsed.EndpointSlices, endpointSlice) } case *networkingv1.Ingress: parsed.Ingresses = append(parsed.Ingresses, res) case *discoveryv1.EndpointSlice: parsed.EndpointSlices = append(parsed.EndpointSlices, res) default: return nil, fmt.Errorf("unsupported resource type %T", obj) } } return parsed, nil } // generatePodsAndEndpointSlice creates Pods and an EndpointSlice for a headless service. func generatePodsAndEndpointSlice(svc *corev1.Service, deps *PodDependencies) ([]*corev1.Pod, *discoveryv1.EndpointSlice) { var pods []*corev1.Pod var endpoints []discoveryv1.Endpoint for i := range deps.Replicas { podName := fmt.Sprintf("%s-%d", svc.Name, i) podIP := fmt.Sprintf("10.0.0.%d", i+1) pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: podName, Namespace: svc.Namespace, Labels: svc.Spec.Selector, }, Spec: corev1.PodSpec{ Hostname: podName, }, Status: corev1.PodStatus{ PodIP: podIP, }, } pods = append(pods, pod) endpoints = append(endpoints, discoveryv1.Endpoint{ Addresses: []string{podIP}, TargetRef: &corev1.ObjectReference{ Kind: "Pod", Name: podName, }, Conditions: discoveryv1.EndpointConditions{ Ready: testutils.ToPtr(true), }, }) } // Create EndpointSlice with the service name label endpointSliceLabels := maps.Clone(svc.Spec.Selector) endpointSliceLabels[discoveryv1.LabelServiceName] = svc.Name endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("%s-slice", svc.Name), Namespace: svc.Namespace, Labels: endpointSliceLabels, }, AddressType: discoveryv1.AddressTypeIPv4, Endpoints: endpoints, } return pods, endpointSlice } func createIngressWithOptionalStatus(ctx context.Context, client *fake.Clientset, ing *networkingv1.Ingress) error { created, err := client.NetworkingV1().Ingresses(ing.Namespace).Create(ctx, ing, metav1.CreateOptions{}) if err != nil { return err } // Update status separately since Create doesn't set status in the fake client. if len(ing.Status.LoadBalancer.Ingress) > 0 { created.Status = ing.Status _, err = client.NetworkingV1().Ingresses(ing.Namespace).UpdateStatus(ctx, created, metav1.UpdateOptions{}) if err != nil { return err } } return nil } func createServiceWithOptionalStatus(ctx context.Context, client *fake.Clientset, svc *corev1.Service) error { created, err := client.CoreV1().Services(svc.Namespace).Create(ctx, svc, metav1.CreateOptions{}) if err != nil { return err } // Update status separately since Create doesn't set status in the fake client. if len(svc.Status.LoadBalancer.Ingress) > 0 { created.Status = svc.Status _, err = client.CoreV1().Services(svc.Namespace).UpdateStatus(ctx, created, metav1.UpdateOptions{}) if err != nil { return err } } return nil } // LoadResources creates the resources in the fake client using the API. // This must be called BEFORE creating sources so the informers can see the resources. func LoadResources(ctx context.Context, scenario Scenario) (*fake.Clientset, error) { client := fake.NewClientset() // Parse resources from scenario resources, err := ParseResources(scenario.Resources) if err != nil { return nil, err } for _, ing := range resources.Ingresses { if err := createIngressWithOptionalStatus(ctx, client, ing); err != nil { return nil, err } } for _, svc := range resources.Services { if err := createServiceWithOptionalStatus(ctx, client, svc); err != nil { return nil, err } } for _, pod := range resources.Pods { _, err := client.CoreV1().Pods(pod.Namespace).Create(ctx, pod, metav1.CreateOptions{}) if err != nil { return nil, err } } for _, eps := range resources.EndpointSlices { _, err := client.DiscoveryV1().EndpointSlices(eps.Namespace).Create(ctx, eps, metav1.CreateOptions{}) if err != nil { return nil, err } } return client, nil } // scenarioToConfig creates a source.Config for testing with the scenario config. func scenarioToConfig(scenarioCfg ScenarioConfig) *source.Config { return source.NewSourceConfig(&externaldns.Config{ Sources: scenarioCfg.Sources, ServiceTypeFilter: scenarioCfg.ServiceTypeFilter, DefaultTargets: scenarioCfg.DefaultTargets, ForceDefaultTargets: scenarioCfg.ForceDefaultTargets, TargetNetFilter: scenarioCfg.TargetNetFilter, }) } // CreateWrappedSource creates sources using source.BuildWithConfig and wraps them with wrappers.WrapSources. // TODO: could we reuse the same source.BuildWithConfig() code as the controller instead of duplicating it here? It would require refactoring to allow passing in a custom client generator, but it would ensure we're testing the same code as the controller. func CreateWrappedSource( ctx context.Context, client *fake.Clientset, scenarioCfg ScenarioConfig) (source.Source, error) { clientGen := newMockClientGenerator(client) cfg := scenarioToConfig(scenarioCfg) // TODO: copied from controller/execute.go#buildSources sources, err := source.ByNames(ctx, cfg, clientGen) if err != nil { return nil, err } opts := wrappers.NewConfig( wrappers.WithDefaultTargets(cfg.DefaultTargets), wrappers.WithForceDefaultTargets(cfg.ForceDefaultTargets), wrappers.WithNAT64Networks(cfg.NAT64Networks), wrappers.WithTargetNetFilter(cfg.TargetNetFilter), wrappers.WithExcludeTargetNets(cfg.ExcludeTargetNets), wrappers.WithMinTTL(cfg.MinTTL)) return wrappers.WrapSources(sources, opts) } // TODO: copied from source/wrappers/source_test.go - unify in following PR func ValidateEndpoints(t *testing.T, endpoints, expected []*endpoint.Endpoint) { t.Helper() if len(endpoints) != len(expected) { t.Fatalf("expected %d endpoints, got %d", len(expected), len(endpoints)) } // Make sure endpoints are sorted - validateEndpoint() depends on it. sortEndpoints(endpoints) sortEndpoints(expected) for i := range endpoints { validateEndpoint(t, endpoints[i], expected[i]) } } // TODO: copied from source/wrappers/source_test.go - unify in following PR func validateEndpoint(t *testing.T, endpoint, expected *endpoint.Endpoint) { t.Helper() if endpoint.DNSName != expected.DNSName { t.Errorf("DNSName expected %q, got %q", expected.DNSName, endpoint.DNSName) } if !endpoint.Targets.Same(expected.Targets) { t.Errorf("Targets expected %q, got %q", expected.Targets, endpoint.Targets) } if endpoint.RecordTTL != expected.RecordTTL { t.Errorf("RecordTTL expected %v, got %v", expected.RecordTTL, endpoint.RecordTTL) } // if a non-empty record type is expected, check that it matches. if endpoint.RecordType != expected.RecordType { t.Errorf("RecordType expected %q, got %q", expected.RecordType, endpoint.RecordType) } // if non-empty labels are expected, check that they match. if expected.Labels != nil && !reflect.DeepEqual(endpoint.Labels, expected.Labels) { t.Errorf("Labels expected %s, got %s", expected.Labels, endpoint.Labels) } if (len(expected.ProviderSpecific) != 0 || len(endpoint.ProviderSpecific) != 0) && !reflect.DeepEqual(endpoint.ProviderSpecific, expected.ProviderSpecific) { t.Errorf("ProviderSpecific expected %s, got %s", expected.ProviderSpecific, endpoint.ProviderSpecific) } if endpoint.SetIdentifier != expected.SetIdentifier { t.Errorf("SetIdentifier expected %q, got %q", expected.SetIdentifier, endpoint.SetIdentifier) } } // TODO: copied from source/wrappers/source_test.go - unify in following PR func sortEndpoints(endpoints []*endpoint.Endpoint) { for _, ep := range endpoints { sort.Strings(ep.Targets) } sort.Slice(endpoints, func(i, k int) bool { // Sort by DNSName, RecordType, and Targets ei, ek := endpoints[i], endpoints[k] if ei.DNSName != ek.DNSName { return ei.DNSName < ek.DNSName } if ei.RecordType != ek.RecordType { return ei.RecordType < ek.RecordType } // Targets are sorted ahead of time. for j, ti := range ei.Targets { if j >= len(ek.Targets) { return true } if tk := ek.Targets[j]; ti != tk { return ti < tk } } return false }) }