diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index d9195b396..4ff5e91ae 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -7,8 +7,10 @@ assignees: '' --- - **What happened**: @@ -17,6 +19,10 @@ assignees: '' **How to reproduce it (as minimally and precisely as possible)**: + + **Anything else we need to know?**: **Environment**: diff --git a/.github/workflows/dependency-update.yaml b/.github/workflows/dependency-update.yaml index 46de02a9e..e24f540f8 100644 --- a/.github/workflows/dependency-update.yaml +++ b/.github/workflows/dependency-update.yaml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v4.2.2 # https://github.com/renovatebot/github-action - name: self-hosted renovate - uses: renovatebot/github-action@v43.0.1 + uses: renovatebot/github-action@v43.0.5 with: # https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c04b5e890..c89e3db88 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -52,7 +52,7 @@ jobs: # Run Spectral - name: Lint OpenAPI spec - uses: stoplightio/spectral-action@577bade2d6e0eeb50528c94182a5588bf961ae8f # v0.8.12 + uses: stoplightio/spectral-action@6416fd018ae38e60136775066eb3e98172143141 # v0.8.13 with: file_glob: 'api/*.yaml' diff --git a/.golangci.yml b/.golangci.yml index c67a5494e..971c456f1 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,6 +26,7 @@ linters: - 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/usage/linters/#nilnil + - nonamedreturns # Checks that functions with named return values do not return named values. https://golangci-lint.run/usage/linters/#nonamedreturns - cyclop # Checks function and package cyclomatic complexity. https://golangci-lint.run/usage/linters/#cyclop # tests @@ -40,7 +41,7 @@ linters: - name: confusing-naming disabled: true cyclop: # Lower cyclomatic complexity threshold after the max complexity is lowered - max-complexity: 51 + max-complexity: 44 testifylint: # Enable all checkers (https://github.com/Antonboom/testifylint#checkers). # Default: false diff --git a/README.md b/README.md index b66de266b..74c6c5cba 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ ExternalDNS allows you to keep selected zones (via `--domain-filter`) synchroniz - [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. @@ -81,7 +82,11 @@ 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. -Known providers using webhooks: +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 | | --------------------- | -------------------------------------------------------------------- | @@ -100,6 +105,7 @@ Known providers using webhooks: | IONOS | https://github.com/ionos-cloud/external-dns-ionos-webhook | | Infoblox | https://github.com/AbsaOSS/external-dns-infoblox-webhook | | 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 | @@ -199,6 +205,7 @@ The following tutorials are provided: - [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) diff --git a/apis/OWNERS b/apis/OWNERS new file mode 100644 index 000000000..16088024f --- /dev/null +++ b/apis/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- apis diff --git a/charts/external-dns/CHANGELOG.md b/charts/external-dns/CHANGELOG.md index 16a129a9a..b0e88965d 100644 --- a/charts/external-dns/CHANGELOG.md +++ b/charts/external-dns/CHANGELOG.md @@ -18,12 +18,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +## [v1.18.0] - 2025-07-14 + ### Changed - Update RBAC for `Service` source to support `EndpointSlices`. ([#5493](https://github.com/kubernetes-sigs/external-dns/pull/5493)) _@vflaux_ +- Update _ExternalDNS_ OCI image version to [v0.18.0](https://github.com/kubernetes-sigs/external-dns/releases/tag/v0.18.0). ([#5633](https://github.com/kubernetes-sigs/external-dns/pull/5633)) _@elafarge_ ### Fixed +- Fixed the lack of schema support for `create-only` dns policy in helm values ([#5627](https://github.com/kubernetes-sigs/external-dns/pull/5627)) _@coltonhughes_ - Fixed the type of `.extraContainers` from `object` to `list` (array). ([#5564](https://github.com/kubernetes-sigs/external-dns/pull/5564)) _@svengreb_ ## [v1.17.0] - 2025-06-04 @@ -275,6 +279,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 RELEASE LINKS --> [UNRELEASED]: https://github.com/kubernetes-sigs/external-dns/tree/master/charts/external-dns +[v1.18.0]: https://github.com/kubernetes-sigs/external-dns/releases/tag/external-dns-helm-chart-1.18.0 [v1.17.0]: https://github.com/kubernetes-sigs/external-dns/releases/tag/external-dns-helm-chart-1.17.0 [v1.16.1]: https://github.com/kubernetes-sigs/external-dns/releases/tag/external-dns-helm-chart-1.16.1 [v1.16.0]: https://github.com/kubernetes-sigs/external-dns/releases/tag/external-dns-helm-chart-1.16.0 diff --git a/charts/external-dns/Chart.yaml b/charts/external-dns/Chart.yaml index 89445c3ec..c1e7c6ce9 100644 --- a/charts/external-dns/Chart.yaml +++ b/charts/external-dns/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: external-dns description: ExternalDNS synchronizes exposed Kubernetes Services and Ingresses with DNS providers. type: application -version: 1.17.0 -appVersion: 0.17.0 +version: 1.18.0 +appVersion: 0.18.0 keywords: - kubernetes - k8s diff --git a/charts/external-dns/README.md b/charts/external-dns/README.md index 24f7ed9b3..33286d914 100644 --- a/charts/external-dns/README.md +++ b/charts/external-dns/README.md @@ -1,6 +1,6 @@ # external-dns -![Version: 1.17.0](https://img.shields.io/badge/Version-1.17.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.17.0](https://img.shields.io/badge/AppVersion-0.17.0-informational?style=flat-square) +![Version: 1.18.0](https://img.shields.io/badge/Version-1.18.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.18.0](https://img.shields.io/badge/AppVersion-0.18.0-informational?style=flat-square) ExternalDNS synchronizes exposed Kubernetes Services and Ingresses with DNS providers. @@ -27,7 +27,7 @@ helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/ After you've installed the repo you can install the chart. ```shell -helm upgrade --install external-dns external-dns/external-dns --version 1.17.0 +helm upgrade --install external-dns external-dns/external-dns --version 1.18.0 ``` ## Providers @@ -109,6 +109,7 @@ If `namespaced` is set to `true`, please ensure that `sources` my only contains | extraVolumeMounts | list | `[]` | Extra [volume mounts](https://kubernetes.io/docs/concepts/storage/volumes/) for the `external-dns` container. | | extraVolumes | list | `[]` | Extra [volumes](https://kubernetes.io/docs/concepts/storage/volumes/) for the `Pod`. | | fullnameOverride | string | `nil` | Override the full name of the chart. | +| gatewayNamespace | string | `nil` | _Gateway API_ gateway namespace to watch. | | global.imagePullSecrets | list | `[]` | Global image pull secrets. | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy for the `external-dns` container. | | image.repository | string | `"registry.k8s.io/external-dns/external-dns"` | Image repository for the `external-dns` container. | @@ -127,7 +128,7 @@ If `namespaced` is set to `true`, please ensure that `sources` my only contains | podAnnotations | object | `{}` | Annotations to add to the `Pod`. | | podLabels | object | `{}` | Labels to add to the `Pod`. | | podSecurityContext | object | See _values.yaml_ | [Pod security context](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#podsecuritycontext-v1-core), this supports full customisation. | -| policy | string | `"upsert-only"` | How DNS records are synchronized between sources and providers; available values are `sync` & `upsert-only`. | +| policy | string | `"upsert-only"` | How DNS records are synchronized between sources and providers; available values are `create-only`, `sync`, & `upsert-only`. | | priorityClassName | string | `nil` | Priority class name for the `Pod`. | | provider.name | string | `"aws"` | _ExternalDNS_ provider name; for the available providers and how to configure them see [README](https://github.com/kubernetes-sigs/external-dns/blob/master/charts/external-dns/README.md#providers). | | provider.webhook.args | list | `[]` | Extra arguments to provide for the `webhook` container. | diff --git a/charts/external-dns/templates/_helpers.tpl b/charts/external-dns/templates/_helpers.tpl index a7277c8ef..aad09822e 100644 --- a/charts/external-dns/templates/_helpers.tpl +++ b/charts/external-dns/templates/_helpers.tpl @@ -103,3 +103,12 @@ labelSelector: matchLabels: {{ include "external-dns.selectorLabels" . | nindent 4 }} {{- end }} + +{{/* +Check if any Gateway API sources are enabled +*/}} +{{- define "external-dns.hasGatewaySources" -}} +{{- if or (has "gateway-httproute" .Values.sources) (has "gateway-grpcroute" .Values.sources) (has "gateway-tlsroute" .Values.sources) (has "gateway-tcproute" .Values.sources) (has "gateway-udproute" .Values.sources) -}} +true +{{- end -}} +{{- end }} diff --git a/charts/external-dns/templates/clusterrole.yaml b/charts/external-dns/templates/clusterrole.yaml index a3d8cb777..f418416e2 100644 --- a/charts/external-dns/templates/clusterrole.yaml +++ b/charts/external-dns/templates/clusterrole.yaml @@ -58,14 +58,18 @@ rules: resources: ["dnsendpoints/status"] verbs: ["*"] {{- end }} -{{- if or (has "gateway-httproute" .Values.sources) (has "gateway-grpcroute" .Values.sources) (has "gateway-tlsroute" .Values.sources) (has "gateway-tcproute" .Values.sources) (has "gateway-udproute" .Values.sources) }} +{{- if include "external-dns.hasGatewaySources" . }} +{{- if or (not .Values.namespaced) (and .Values.namespaced (not .Values.gatewayNamespace)) }} - apiGroups: ["gateway.networking.k8s.io"] resources: ["gateways"] verbs: ["get","watch","list"] +{{- end }} +{{- if not .Values.namespaced }} - apiGroups: [""] resources: ["namespaces"] verbs: ["get","watch","list"] {{- end }} +{{- end }} {{- if has "gateway-httproute" .Values.sources }} - apiGroups: ["gateway.networking.k8s.io"] resources: ["httproutes"] @@ -127,4 +131,31 @@ rules: {{- with .Values.rbac.additionalPermissions }} {{- toYaml . | nindent 2 }} {{- end }} +{{- if and .Values.rbac.create .Values.namespaced (include "external-dns.hasGatewaySources" .) }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ template "external-dns.fullname" . }}-namespaces + labels: + {{- include "external-dns.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get","watch","list"] +{{- if .Values.gatewayNamespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ template "external-dns.fullname" . }}-gateway + namespace: {{ .Values.gatewayNamespace }} + labels: + {{- include "external-dns.labels" . | nindent 4 }} +rules: + - apiGroups: ["gateway.networking.k8s.io"] + resources: ["gateways"] + verbs: ["get","watch","list"] +{{- end }} +{{- end }} {{- end }} diff --git a/charts/external-dns/templates/clusterrolebinding.yaml b/charts/external-dns/templates/clusterrolebinding.yaml index 74a51476f..49400c0be 100644 --- a/charts/external-dns/templates/clusterrolebinding.yaml +++ b/charts/external-dns/templates/clusterrolebinding.yaml @@ -13,4 +13,39 @@ subjects: - kind: ServiceAccount name: {{ template "external-dns.serviceAccountName" . }} namespace: {{ .Release.Namespace }} +{{- if and .Values.rbac.create .Values.namespaced (include "external-dns.hasGatewaySources" .) }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ template "external-dns.fullname" . }}-namespaces + labels: + {{- include "external-dns.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ template "external-dns.fullname" . }}-namespaces +subjects: + - kind: ServiceAccount + name: {{ template "external-dns.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- if .Values.gatewayNamespace }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ template "external-dns.fullname" . }}-gateway + namespace: {{ .Values.gatewayNamespace }} + labels: + {{- include "external-dns.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ template "external-dns.fullname" . }}-gateway +subjects: + - kind: ServiceAccount + name: {{ template "external-dns.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} +{{- end }} {{- end }} diff --git a/charts/external-dns/templates/deployment.yaml b/charts/external-dns/templates/deployment.yaml index f0c967a33..7db118370 100644 --- a/charts/external-dns/templates/deployment.yaml +++ b/charts/external-dns/templates/deployment.yaml @@ -111,6 +111,9 @@ spec: {{- if .Values.namespaced }} - --namespace={{ .Release.Namespace }} {{- end }} + {{- if .Values.gatewayNamespace }} + - --gateway-namespace={{ .Values.gatewayNamespace }} + {{- end }} {{- range .Values.domainFilters }} - --domain-filter={{ . }} {{- end }} diff --git a/charts/external-dns/tests/rbac_test.yaml b/charts/external-dns/tests/rbac_test.yaml index 9e061c649..c8fcaea2c 100644 --- a/charts/external-dns/tests/rbac_test.yaml +++ b/charts/external-dns/tests/rbac_test.yaml @@ -156,3 +156,238 @@ tests: - apiGroups: ["gateway.networking.k8s.io"] resources: ["udproutes"] verbs: ["get","watch","list"] + + - it: should create Role instead of ClusterRole when namespaced is true + set: + namespaced: true + sources: + - service + asserts: + - isKind: + of: Role + template: clusterrole.yaml + - equal: + path: metadata.name + value: rbac-external-dns + template: clusterrole.yaml + + - it: should create RoleBinding instead of ClusterRoleBinding when namespaced is true + set: + namespaced: true + sources: + - service + asserts: + - isKind: + of: RoleBinding + template: clusterrolebinding.yaml + - equal: + path: metadata.name + value: rbac-external-dns-viewer + template: clusterrolebinding.yaml + + - it: should create all required resources when namespaced=true and gatewayNamespace is specified + set: + namespaced: true + gatewayNamespace: gateway-ns + sources: + - gateway-httproute + asserts: + # Should have: main Role + ClusterRole for namespaces + Gateway Role + - hasDocuments: + count: 3 + template: clusterrole.yaml + - hasDocuments: + count: 3 + template: clusterrolebinding.yaml + + # Main role should exist and contain route permissions but NOT gateway permissions + - isKind: + of: Role + documentSelector: + path: metadata.name + value: rbac-external-dns + template: clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["gateway.networking.k8s.io"] + resources: ["httproutes"] + verbs: ["get","watch","list"] + documentSelector: + path: metadata.name + value: rbac-external-dns + template: clusterrole.yaml + - notContains: + path: rules + content: + apiGroups: ["gateway.networking.k8s.io"] + resources: ["gateways"] + verbs: ["get","watch","list"] + documentSelector: + path: metadata.name + value: rbac-external-dns + template: clusterrole.yaml + + # ClusterRole for namespaces should exist + - isKind: + of: ClusterRole + documentSelector: + path: metadata.name + value: rbac-external-dns-namespaces + template: clusterrole.yaml + + # Gateway role should exist and have gateway permissions only + - isKind: + of: Role + documentSelector: + path: metadata.name + value: rbac-external-dns-gateway + template: clusterrole.yaml + - equal: + path: metadata.namespace + value: gateway-ns + documentSelector: + path: metadata.name + value: rbac-external-dns-gateway + template: clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["gateway.networking.k8s.io"] + resources: ["gateways"] + verbs: ["get","watch","list"] + documentSelector: + path: metadata.name + value: rbac-external-dns-gateway + template: clusterrole.yaml + + + - it: should create main Role with gateway permissions and ClusterRole for namespaces when namespaced=true and gatewayNamespace is not set + set: + namespaced: true + sources: + - gateway-httproute + asserts: + # Should have: main Role + ClusterRole for namespaces + - hasDocuments: + count: 2 + template: clusterrole.yaml + - hasDocuments: + count: 2 + template: clusterrolebinding.yaml + # Main Role should exist and contain gateway permissions + - isKind: + of: Role + documentSelector: + path: metadata.name + value: rbac-external-dns + template: clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["gateway.networking.k8s.io"] + resources: ["gateways"] + verbs: ["get","watch","list"] + documentSelector: + path: metadata.name + value: rbac-external-dns + template: clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["gateway.networking.k8s.io"] + resources: ["httproutes"] + verbs: ["get","watch","list"] + documentSelector: + path: metadata.name + value: rbac-external-dns + template: clusterrole.yaml + # ClusterRole for namespaces should exist + - isKind: + of: ClusterRole + documentSelector: + path: metadata.name + value: rbac-external-dns-namespaces + template: clusterrole.yaml + - contains: + path: rules + content: + apiGroups: [""] + resources: ["namespaces"] + verbs: ["get","watch","list"] + documentSelector: + path: metadata.name + value: rbac-external-dns-namespaces + template: clusterrole.yaml + + - it: should create ClusterRole with all permissions when namespaced=false and gateway sources exist + set: + namespaced: false + sources: + - gateway-httproute + asserts: + - hasDocuments: + count: 1 + template: clusterrole.yaml + - hasDocuments: + count: 1 + template: clusterrolebinding.yaml + - isKind: + of: ClusterRole + template: clusterrole.yaml + - contains: + path: rules + content: + apiGroups: ["gateway.networking.k8s.io"] + resources: ["gateways"] + verbs: ["get","watch","list"] + template: clusterrole.yaml + - contains: + path: rules + content: + apiGroups: [""] + resources: ["namespaces"] + verbs: ["get","watch","list"] + template: clusterrole.yaml + + - it: should create only ClusterRole when namespaced=false and no gateway sources + set: + namespaced: false + sources: + - service + - ingress + asserts: + - hasDocuments: + count: 1 + template: clusterrole.yaml + - hasDocuments: + count: 1 + template: clusterrolebinding.yaml + - isKind: + of: ClusterRole + template: clusterrole.yaml + - notContains: + path: rules + content: + apiGroups: ["gateway.networking.k8s.io"] + template: clusterrole.yaml + + - it: should create only Role when namespaced=true and no gateway sources + set: + namespaced: true + sources: + - service + - ingress + asserts: + - hasDocuments: + count: 1 + template: clusterrole.yaml + - hasDocuments: + count: 1 + template: clusterrolebinding.yaml + - isKind: + of: Role + template: clusterrole.yaml + - isKind: + of: RoleBinding + template: clusterrolebinding.yaml diff --git a/charts/external-dns/values.schema.json b/charts/external-dns/values.schema.json index a4de3f455..75e7fdd42 100644 --- a/charts/external-dns/values.schema.json +++ b/charts/external-dns/values.schema.json @@ -270,12 +270,13 @@ } }, "policy": { - "description": "How DNS records are synchronized between sources and providers; available values are `sync` \u0026 `upsert-only`.", + "description": "How DNS records are synchronized between sources and providers; available values are `create-only`, `sync`, \u0026 `upsert-only`.", "default": "upsert-only", "type": [ "string" ], "enum": [ + "create-only", "sync", "upsert-only" ] diff --git a/charts/external-dns/values.yaml b/charts/external-dns/values.yaml index a6fa3212b..dc5ec8d1e 100644 --- a/charts/external-dns/values.yaml +++ b/charts/external-dns/values.yaml @@ -205,13 +205,16 @@ triggerLoopOnEvent: false # -- if `true`, _ExternalDNS_ will run in a namespaced scope (`Role`` and `Rolebinding`` will be namespaced too). namespaced: false +# -- _Gateway API_ gateway namespace to watch. +gatewayNamespace: # @schema type:[string, null]; default: null + # -- _Kubernetes_ resources to monitor for DNS entries. sources: - service - ingress -# -- How DNS records are synchronized between sources and providers; available values are `sync` & `upsert-only`. -policy: upsert-only # @schema enum:[sync, upsert-only]; type:string; default: "upsert-only" +# -- How DNS records are synchronized between sources and providers; available values are `create-only`, `sync`, & `upsert-only`. +policy: upsert-only # @schema enum:[create-only, sync, upsert-only]; type:string; default: "upsert-only" # -- Specify the registry for storing ownership and labels. # Valid values are `txt`, `aws-sd`, `dynamodb` & `noop`. diff --git a/controller/OWNERS b/controller/OWNERS new file mode 100644 index 000000000..5e51c9fef --- /dev/null +++ b/controller/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- controller diff --git a/controller/controller.go b/controller/controller.go index 06562da58..220da28ce 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -37,7 +37,6 @@ import ( var ( registryErrorsTotal = metrics.NewCounterWithOpts( prometheus.CounterOpts{ - Namespace: "external_dns", Subsystem: "registry", Name: "errors_total", Help: "Number of Registry errors.", @@ -45,7 +44,6 @@ var ( ) sourceErrorsTotal = metrics.NewCounterWithOpts( prometheus.CounterOpts{ - Namespace: "external_dns", Subsystem: "source", Name: "errors_total", Help: "Number of Source errors.", @@ -53,7 +51,6 @@ var ( ) sourceEndpointsTotal = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "source", Name: "endpoints_total", Help: "Number of Endpoints in all sources", @@ -61,7 +58,6 @@ var ( ) registryEndpointsTotal = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "registry", Name: "endpoints_total", Help: "Number of Endpoints in the registry", @@ -69,7 +65,6 @@ var ( ) lastSyncTimestamp = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "controller", Name: "last_sync_timestamp_seconds", Help: "Timestamp of last successful sync with the DNS provider", @@ -77,7 +72,6 @@ var ( ) lastReconcileTimestamp = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "controller", Name: "last_reconcile_timestamp_seconds", Help: "Timestamp of last attempted sync with the DNS provider", @@ -85,7 +79,6 @@ var ( ) controllerNoChangesTotal = metrics.NewCounterWithOpts( prometheus.CounterOpts{ - Namespace: "external_dns", Subsystem: "controller", Name: "no_op_runs_total", Help: "Number of reconcile loops ending up with no changes on the DNS provider side.", @@ -108,7 +101,6 @@ var ( registryRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "registry", Name: "records", Help: "Number of registry records partitioned by label name (vector).", @@ -118,7 +110,6 @@ var ( sourceRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "source", Name: "records", Help: "Number of source records partitioned by label name (vector).", @@ -128,7 +119,6 @@ var ( verifiedRecords = metrics.NewGaugedVectorOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "controller", Name: "verified_records", Help: "Number of DNS records that exists both in source and registry (vector).", @@ -138,7 +128,6 @@ var ( consecutiveSoftErrors = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "controller", Name: "consecutive_soft_errors", Help: "Number of consecutive soft errors in reconciliation loop.", diff --git a/controller/execute.go b/controller/execute.go index 508a6f4e8..a69a1a7ae 100644 --- a/controller/execute.go +++ b/controller/execute.go @@ -33,6 +33,8 @@ import ( log "github.com/sirupsen/logrus" "k8s.io/klog/v2" + "sigs.k8s.io/external-dns/source/wrappers" + "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/pkg/apis/externaldns" "sigs.k8s.io/external-dns/pkg/apis/externaldns/validation" @@ -423,11 +425,11 @@ func buildSource(ctx context.Context, cfg *externaldns.Config) (source.Source, e return nil, err } // Combine multiple sources into a single, deduplicated source. - combinedSource := source.NewDedupSource(source.NewMultiSource(sources, sourceCfg.DefaultTargets, sourceCfg.ForceDefaultTargets)) + combinedSource := wrappers.NewDedupSource(wrappers.NewMultiSource(sources, sourceCfg.DefaultTargets, sourceCfg.ForceDefaultTargets)) // Filter targets targetFilter := endpoint.NewTargetNetFilterWithExclusions(cfg.TargetNetFilter, cfg.ExcludeTargetNets) - combinedSource = source.NewNAT64Source(combinedSource, cfg.NAT64Networks) - combinedSource = source.NewTargetFilterSource(combinedSource, targetFilter) + combinedSource = wrappers.NewNAT64Source(combinedSource, cfg.NAT64Networks) + combinedSource = wrappers.NewTargetFilterSource(combinedSource, targetFilter) return combinedSource, nil } diff --git a/docs/annotations/annotations.md b/docs/annotations/annotations.md index c5c172f23..bc3cffc10 100644 --- a/docs/annotations/annotations.md +++ b/docs/annotations/annotations.md @@ -66,6 +66,73 @@ Multiple hostnames can be specified through a comma-separated list, e.g. 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 `overrides` any automatically derived hostnames (e.g., from Ingress.spec.rules[].host). +- 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. diff --git a/docs/contributing/dev-guide.md b/docs/contributing/dev-guide.md index a88632256..8a04c7f6f 100644 --- a/docs/contributing/dev-guide.md +++ b/docs/contributing/dev-guide.md @@ -70,11 +70,11 @@ import ( ) func TestMe(t *testing.T) { - hook := testutils.LogsUnderTestWithLogLeve(log.WarnLevel, t) + hook := testutils.LogsUnderTestWithLogLevel(log.WarnLevel, t) ... function under tests ... testutils.TestHelperLogContains("example warning message", hook, t) // provide negative assertion - testuitls.TestHelperLogNotContains("this message should not be shown", hook, t) + testutils.TestHelperLogNotContains("this message should not be shown", hook, t) } ``` diff --git a/docs/flags.md b/docs/flags.md index 14a045761..59f928882 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -50,7 +50,7 @@ | `--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) | | `--source=source` | The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, fake, connector, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, cloudfoundry, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy) | | `--target-net-filter=TARGET-NET-FILTER` | Limit possible targets by a net filter; specify multiple times for multiple possible nets (optional) | -| `--[no-]traefik-disable-legacy` | Disable listeners on Resources under the traefik.containo.us API Group | +| `--[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 | | `--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, digitalocean, dnsimple, exoscale, gandi, godaddy, google, inmemory, linode, ns1, oci, ovh, pdns, pihole, plural, rfc2136, scaleway, skydns, transip, webhook) | | `--provider-cache-time=0s` | The time to cache the DNS provider record list requests. | @@ -66,7 +66,7 @@ | `--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, 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) | diff --git a/docs/monitoring/metrics.md b/docs/monitoring/metrics.md index c80d39333..3da6d52af 100644 --- a/docs/monitoring/metrics.md +++ b/docs/monitoring/metrics.md @@ -20,6 +20,7 @@ curl https://localhost:7979/metrics | 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 | diff --git a/docs/providers.md b/docs/providers.md index 228622939..0aafa2fdd 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -20,6 +20,7 @@ Provider supported configurations | 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 | diff --git a/docs/registry/txt.md b/docs/registry/txt.md index e3ae66d65..d538eb28e 100644 --- a/docs/registry/txt.md +++ b/docs/registry/txt.md @@ -118,19 +118,19 @@ Note that the key used for encryption should be a secure key and properly manage Python ```python -python -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())' +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 | tr -d -- '\n' | tr -- '+/' '-_'; echo +dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64; echo ``` OpenSSL ```shell -openssl rand -base64 32 | tr -- '+/' '-_' +openssl rand -base64 32 ``` PowerShell @@ -138,7 +138,7 @@ 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))).Replace("+","-").Replace("/","_") +[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.Web.Security.Membership]::GeneratePassword(32,4))) ``` Terraform @@ -146,7 +146,6 @@ Terraform ```hcl resource "random_password" "txt_key" { length = 32 - override_special = "-_" } ``` diff --git a/docs/sources/about.md b/docs/sources/about.md index 1421b9a93..ea76edcac 100644 --- a/docs/sources/about.md +++ b/docs/sources/about.md @@ -5,26 +5,26 @@ A source in ExternalDNS defines where DNS records are discovered from within you 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 | +|-----------------------------------------|-------------------------------------------------------------------------------|:-----------------:|:------------:| +| ambassador-host | Host.getambassador.io | Yes | Yes | | connector | | | | -| contour-httpproxy | HttpProxy.projectcontour.io | Yes | | +| contour-httpproxy | HttpProxy.projectcontour.io | Yes | | | cloudfoundry | | | | -| [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 | +| [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 | | | -| [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 | | +| [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 | | diff --git a/docs/sources/istio.md b/docs/sources/istio.md index 960deb823..e2c046e38 100644 --- a/docs/sources/istio.md +++ b/docs/sources/istio.md @@ -5,9 +5,13 @@ It is meant to supplement the other provider-specific setup tutorials. **Note:** Using the Istio Gateway source requires Istio >=1.0.0. -* Manifest (for clusters without RBAC enabled) -* Manifest (for clusters with RBAC enabled) -* Update existing ExternalDNS Deployment +**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) @@ -119,9 +123,9 @@ spec: ## 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`. +- 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. @@ -148,13 +152,13 @@ The following are relevant snippets from that tutorial. With automatic sidecar injection: ```bash -kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.6/samples/httpbin/httpbin.yaml +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.6/samples/httpbin/httpbin.yaml) +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 @@ -320,13 +324,13 @@ EOF ## Debug ExternalDNS -* Look for the deployment pod to see the status +- Look for the deployment pod to see the status ```console$ kubectl get pods | grep external-dns external-dns-6b84999479-4knv9 1/1 Running 0 3h29m ``` -* Watch for the logs as follows +- Watch for the logs as follows ```console kubectl logs -f external-dns-6b84999479-4knv9 @@ -336,7 +340,7 @@ At this point, you can `create` or `update` any `Istio Gateway` object with `hos > **ATTENTION**: Make sure to specify those whose account is related to the DNS record. -* Successful executions will print the following +- Successful executions will print the following ```console time="2020-01-17T06:08:08Z" level=info msg="Desired change: CREATE httpbin.example.com A" @@ -345,7 +349,7 @@ time="2020-01-17T06:08:08Z" level=info msg="2 record(s) in zone example.com. wer 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: +- 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" diff --git a/docs/sources/traefik-proxy.md b/docs/sources/traefik-proxy.md index 20689a1ab..acb6f1489 100644 --- a/docs/sources/traefik-proxy.md +++ b/docs/sources/traefik-proxy.md @@ -82,15 +82,11 @@ kubectl delete -f externaldns.yaml | Flag | Description | |--------------------------|----------------------------------------------------------| -| --traefik-disable-legacy | Disable listeners on Resources under traefik.containo.us | +| --traefik-enable-legacy | Enable listeners on Resources under traefik.containo.us | | --traefik-disable-new | Disable listeners on Resources under traefik.io | -### Disabling Resource Listeners +### Resource Listeners -Traefik has deprecated the legacy API group, `traefik.containo.us`, in favor of `traefik.io`. By default the `traefik-proxy` source will listen for resources under both API groups; however, this may cause timeouts with the following message +Traefik has deprecated the legacy API group, _traefik.containo.us_, in favor of _traefik.io_. By default the `traefik-proxy` source listen for resources under traefik.io API groups. -```sh -FATA[0060] failed to sync traefik.io/v1alpha1, Resource=ingressroutes: context deadline exceeded -``` - -In this case you can disable one or the other API groups with `--traefik-disable-new` or `--traefik-disable-legacy` +If needed, you can enable legacy listener with `--traefik-enable-legacy` and also disable new listener with `--traefik-disable-new`. diff --git a/docs/tutorials/aws.md b/docs/tutorials/aws.md index a7a9a8034..6585aa9e3 100644 --- a/docs/tutorials/aws.md +++ b/docs/tutorials/aws.md @@ -473,6 +473,8 @@ env: 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 ``` @@ -894,6 +896,11 @@ For any given DNS name, only **one** of the following routing policies can be us - `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` ### Associating DNS records with healthchecks diff --git a/docs/tutorials/cloudflare.md b/docs/tutorials/cloudflare.md index 1147e0e26..4afb55e24 100644 --- a/docs/tutorials/cloudflare.md +++ b/docs/tutorials/cloudflare.md @@ -4,6 +4,37 @@ This tutorial describes how to setup ExternalDNS for usage within a Kubernetes c Make sure to use **>=0.4.2** version of ExternalDNS for this tutorial. +## CloudFlare SDK Migration Status + +ExternalDNS is currently migrating from the legacy CloudFlare Go SDK v0 to the modern v4 SDK to improve performance, reliability, and access to newer CloudFlare features. The migration status is: + +**✅ Fully migrated to v4 SDK:** + +- Zone management (listing, filtering, pagination) +- Zone details retrieval (`GetZone`) +- Zone ID lookup by name (`ZoneIDByName`) +- Zone plan detection (fully v4 implementation) +- Regional services (data localization) + +**🔄 Still using legacy v0 SDK:** + +- DNS record management (create, update, delete records) +- Custom hostnames +- Proxied records + +This mixed approach ensures continued functionality while gradually modernizing the codebase. Users should not experience any breaking changes during this transition. + +### SDK Dependencies + +ExternalDNS currently uses: + +- **cloudflare-go v0.115.0+**: Legacy SDK for DNS records, custom hostnames, and proxied record features +- **cloudflare-go/v4 v4.6.0+**: Modern SDK for all zone management and regional services operations + +Zone management has been fully migrated to the v4 SDK, providing improved performance and reliability. + +Both SDKs are automatically managed as Go module dependencies and require no special configuration from users. + ## Creating a Cloudflare DNS zone We highly recommend to read this tutorial if you haven't used Cloudflare before: @@ -353,7 +384,7 @@ The custom hostname DNS must resolve to the Cloudflare DNS record (`external-dns Requires [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) product and "SSL and Certificates" API permission. -Due to a limitation within the cloudflare-go v0 API, the custom hostname page size is fixed at 50. +**Note:** Due to using the legacy cloudflare-go v0 API for custom hostname management, the custom hostname page size is fixed at 50. This limitation will be addressed in a future migration to the v4 SDK. ## Using CRD source to manage DNS records in Cloudflare diff --git a/docs/tutorials/hostport.md b/docs/tutorials/hostport.md index 30a3f31c1..a789459ea 100644 --- a/docs/tutorials/hostport.md +++ b/docs/tutorials/hostport.md @@ -169,7 +169,7 @@ Very important here, is to set the `hostPort`(only works if the PodSecurityPolic 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` +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: @@ -190,22 +190,24 @@ spec: component: kafka ``` -This will create 3 dns records: +This will create 4 dns records: ```sh -kafka-0.example.org -kafka-1.example.org -kafka-2.example.org +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 ``` -If you set `--fqdn-template={{name}}.example.org` you can omit the annotation. -Generally it is a better approach to use `--fqdn-template={{name}}.example.org`, because then -you would get the service name inside the generated A records: +> !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 -kafka-1.ksvc.example.org -kafka-2.ksvc.example.org +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 diff --git a/docs/tutorials/myra.md b/docs/tutorials/myra.md new file mode 100644 index 000000000..c25e16821 --- /dev/null +++ b/docs/tutorials/myra.md @@ -0,0 +1,215 @@ +# 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. 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 YOUR_REGISTRY/myra-webhook:latest + +# Push to your container registry +docker push YOUR_REGISTRY/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 | +|-----------|-------------|---------| +| `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 diff --git a/endpoint/crypto.go b/endpoint/crypto.go index 253cb227a..cd3fb2f6f 100644 --- a/endpoint/crypto.go +++ b/endpoint/crypto.go @@ -68,7 +68,7 @@ func EncryptText(text string, aesKey []byte, nonceEncoded []byte) (string, error // DecryptText decrypt gziped data using a supplied AES encryption key ang ungzip it // in case of decryption failed, will return original input and decryption error -func DecryptText(text string, aesKey []byte) (decryptResult string, encryptNonce string, err error) { +func DecryptText(text string, aesKey []byte) (string, string, error) { block, err := aes.NewCipher(aesKey) if err != nil { return "", "", err @@ -100,7 +100,7 @@ func DecryptText(text string, aesKey []byte) (decryptResult string, encryptNonce } // decompressData gzip compressed data -func decompressData(data []byte) (resData []byte, err error) { +func decompressData(data []byte) ([]byte, error) { gz, err := gzip.NewReader(bytes.NewBuffer(data)) if err != nil { return nil, err @@ -115,7 +115,7 @@ func decompressData(data []byte) (resData []byte, err error) { } // compressData by gzip, for minify data stored in registry -func compressData(data []byte) (compressedData []byte, err error) { +func compressData(data []byte) ([]byte, error) { var b bytes.Buffer gz, err := gzip.NewWriterLevel(&b, gzip.BestCompression) if err != nil { diff --git a/endpoint/crypto_test.go b/endpoint/crypto_test.go index fd910683d..4feeb073a 100644 --- a/endpoint/crypto_test.go +++ b/endpoint/crypto_test.go @@ -87,6 +87,6 @@ func TestGenerateNonceError(t *testing.T) { type faultyReader struct{} -func (f *faultyReader) Read(p []byte) (n int, err error) { +func (f *faultyReader) Read(p []byte) (int, error) { return 0, io.ErrUnexpectedEOF } diff --git a/endpoint/domain_filter.go b/endpoint/domain_filter.go index 47402bb76..8d8aad2dc 100644 --- a/endpoint/domain_filter.go +++ b/endpoint/domain_filter.go @@ -25,7 +25,8 @@ import ( "strings" log "github.com/sirupsen/logrus" - "golang.org/x/net/idna" + + "sigs.k8s.io/external-dns/internal/idna" ) type MatchAllDomainFilters []DomainFilterInterface @@ -247,9 +248,9 @@ func (df *DomainFilter) MatchParent(domain string) bool { } // normalizeDomain converts a domain to a canonical form, so that we can filter on it -// it: trim "." suffix, get Unicode version of domain complient with Section 5 of RFC 5891 +// it: trim "." suffix, get Unicode version of domain compliant with Section 5 of RFC 5891 func normalizeDomain(domain string) string { - s, err := idna.Lookup.ToUnicode(strings.TrimSuffix(domain, ".")) + s, err := idna.Profile.ToUnicode(strings.TrimSuffix(domain, ".")) if err != nil { log.Warnf(`Got error while parsing domain %s: %v`, domain, err) } diff --git a/endpoint/domain_filter_test.go b/endpoint/domain_filter_test.go index 29dfdfea6..328743464 100644 --- a/endpoint/domain_filter_test.go +++ b/endpoint/domain_filter_test.go @@ -949,3 +949,9 @@ func TestDomainFilterNormalizeDomain(t *testing.T) { 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)) +} diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index 95461a5aa..68f82378a 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -19,6 +19,7 @@ package endpoint import ( "fmt" "net/netip" + "slices" "sort" "strconv" "strings" @@ -353,7 +354,21 @@ 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) } -// Apply filter to slice of endpoints and return new filtered slice that includes +// UniqueOrderedTargets removes duplicate targets from the Endpoint and sorts them in lexicographical order. +func (e *Endpoint) UniqueOrderedTargets() { + result := make([]string, 0, len(e.Targets)) + existing := make(map[string]bool) + for _, target := range e.Targets { + if _, ok := existing[target]; !ok { + result = append(result, target) + existing[target] = true + } + } + slices.Sort(result) + e.Targets = result +} + +// 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{} diff --git a/endpoint/endpoint_test.go b/endpoint/endpoint_test.go index 0c2a3cbb9..d87aaab3c 100644 --- a/endpoint/endpoint_test.go +++ b/endpoint/endpoint_test.go @@ -925,3 +925,46 @@ func TestCheckEndpoint(t *testing.T) { }) } } + +func TestEndpoint_UniqueOrderedTargets(t *testing.T) { + tests := []struct { + name string + targets []string + expected Targets + want bool + }{ + { + name: "no duplicates", + targets: []string{"b.example.com", "a.example.com"}, + expected: Targets{"a.example.com", "b.example.com"}, + }, + { + name: "with duplicates", + targets: []string{"a.example.com", "b.example.com", "a.example.com"}, + expected: Targets{"a.example.com", "b.example.com"}, + }, + { + name: "already sorted", + targets: []string{"a.example.com", "b.example.com"}, + expected: Targets{"a.example.com", "b.example.com"}, + }, + { + name: "all duplicates", + targets: []string{"a.example.com", "a.example.com", "a.example.com"}, + expected: Targets{"a.example.com"}, + }, + { + name: "empty", + targets: []string{}, + expected: Targets{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ep := &Endpoint{Targets: tt.targets} + ep.UniqueOrderedTargets() + assert.Equal(t, tt.expected, ep.Targets) + }) + } +} diff --git a/endpoint/target_filter.go b/endpoint/target_filter.go index e4e69957f..2706155e9 100644 --- a/endpoint/target_filter.go +++ b/endpoint/target_filter.go @@ -26,12 +26,13 @@ import ( // 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 + // filterNets define what targets to match + filterNets []*net.IPNet // excludeNets define what targets not to match excludeNets []*net.IPNet } @@ -42,11 +43,9 @@ func prepareTargetFilters(filters []string) []*net.IPNet { 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 } @@ -57,12 +56,17 @@ func prepareTargetFilters(filters []string) []*net.IPNet { // 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)} + 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) + 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`. @@ -73,9 +77,9 @@ func matchTargetNetFilter(filters []*net.IPNet, target string, emptyval bool) bo return emptyval } - for _, filter := range filters { - ip := net.ParseIP(target) + ip := net.ParseIP(target) + for _, filter := range filters { if filter.Contains(ip) { return true } diff --git a/endpoint/target_filter_test.go b/endpoint/target_filter_test.go index 01ffbf5cf..d803093c1 100644 --- a/endpoint/target_filter_test.go +++ b/endpoint/target_filter_test.go @@ -66,6 +66,18 @@ var targetFilterTests = []targetFilterTest{ []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) { @@ -89,8 +101,21 @@ func TestTargetFilterMatchWithEmptyFilter(t *testing.T) { } } -func TestMatchTargetFilterReturnsProperEmptyVal(t *testing.T) { - emptyFilters := []string{} - assert.True(t, matchFilter(emptyFilters, "sometarget.com", true)) - assert.False(t, matchFilter(emptyFilters, "sometarget.com", false)) +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()) + } } diff --git a/go.mod b/go.mod index 6f1eda1e6..50708ad3f 100644 --- a/go.mod +++ b/go.mod @@ -4,31 +4,32 @@ go 1.24.2 require ( cloud.google.com/go/compute/metadata v0.7.0 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.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.0 - github.com/Yamashou/gqlgenc v0.32.1 + github.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.1 + 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.36.5 - github.com/aws/aws-sdk-go-v2/config v1.29.17 - github.com/aws/aws-sdk-go-v2/credentials v1.17.70 - github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.3 - github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.4 - github.com/aws/aws-sdk-go-v2/service/route53 v1.52.2 - github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.7 - github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 + github.com/aws/aws-sdk-go-v2 v1.36.6 + github.com/aws/aws-sdk-go-v2/config v1.29.18 + github.com/aws/aws-sdk-go-v2/credentials v1.17.71 + github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.6 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.1 + github.com/aws/aws-sdk-go-v2/service/route53 v1.53.1 + github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.8 + github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 github.com/bodgit/tsig v1.2.2 - github.com/cenkalti/backoff/v5 v5.0.2 - github.com/civo/civogo v0.6.1 + github.com/cenkalti/backoff/v5 v5.0.3 + github.com/civo/civogo v0.6.2 github.com/cloudflare/cloudflare-go v0.115.0 + github.com/cloudflare/cloudflare-go/v4 v4.6.0 github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 github.com/datawire/ambassador v1.12.4 github.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace - github.com/digitalocean/godo v1.155.0 + github.com/digitalocean/godo v1.160.0 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 @@ -37,63 +38,63 @@ require ( github.com/goccy/go-yaml v1.18.0 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/linki/instrumented_http v0.3.0 - github.com/linode/linodego v1.52.1 + github.com/linode/linodego v1.53.0 github.com/maxatome/go-testdeep v1.14.0 - github.com/miekg/dns v1.1.66 + github.com/miekg/dns v1.1.67 github.com/openshift/api v0.0.0-20230607130528-611114dca681 github.com/openshift/client-go v0.0.0-20230607134213-3cd0021bbee3 - github.com/oracle/oci-go-sdk/v65 v65.94.0 + github.com/oracle/oci-go-sdk/v65 v65.96.0 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.32.0 github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_model v0.6.2 - github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33 + github.com/prometheus/common v0.65.0 + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.34 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 github.com/transip/gotransip/v6 v6.26.0 - go.etcd.io/etcd/api/v3 v3.6.1 - go.etcd.io/etcd/client/v3 v3.6.1 + go.etcd.io/etcd/api/v3 v3.6.4 + go.etcd.io/etcd/client/v3 v3.6.4 go.uber.org/ratelimit v0.3.1 - golang.org/x/net v0.41.0 + golang.org/x/net v0.42.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.15.0 - golang.org/x/text v0.26.0 + golang.org/x/sync v0.16.0 + golang.org/x/text v0.27.0 golang.org/x/time v0.12.0 - google.golang.org/api v0.239.0 + google.golang.org/api v0.243.0 gopkg.in/ns1/ns1-go.v2 v2.14.4 istio.io/api v1.26.2 istio.io/client-go v1.26.2 - k8s.io/api v0.33.2 - k8s.io/apimachinery v0.33.2 - k8s.io/client-go v0.33.2 + k8s.io/api v0.33.3 + k8s.io/apimachinery v0.33.3 + k8s.io/client-go v0.33.3 k8s.io/klog/v2 v2.130.1 sigs.k8s.io/controller-runtime v0.21.0 sigs.k8s.io/gateway-api v1.3.0 ) require ( - cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go/auth v0.16.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f // indirect - github.com/99designs/gqlgen v0.17.71 // indirect + github.com/99designs/gqlgen v0.17.73 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.6 // indirect + github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.1 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 // indirect github.com/aws/smithy-go v1.22.4 // indirect github.com/benbjohnson/clock v1.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -120,7 +121,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/googleapis/gax-go/v2 v2.15.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 @@ -153,7 +154,6 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/common v0.64.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/schollz/progressbar/v3 v3.8.6 // indirect @@ -162,11 +162,15 @@ require ( github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/vektah/gqlparser/v2 v2.5.25 // 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.1 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.36.0 // indirect @@ -175,13 +179,13 @@ require ( go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.39.0 // indirect + golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect - golang.org/x/tools v0.33.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/term v0.33.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/go.sum b/go.sum index 239bac249..5c773b789 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxo 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.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= -cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= +cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= 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.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= @@ -12,12 +12,12 @@ code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f h1:UrKzEwTg code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f/go.mod h1:sk5LnIjB/nIEU7yP5sDQExVm62wu0pBh3yrElngUisI= 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.71 h1:6JdwweHlSMWGY+6VWY5ey0tO+sF8LckbUV0NmdOQi04= -github.com/99designs/gqlgen v0.17.71/go.mod h1:3yz6ekwCAjC90zaFvPoy+mEjaKiyYJjhtCnwn1seoxE= +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.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 h1:Wc1ml6QlJs2BHQ/9Bqu1jiyggbsSjramq2oUmp5WeIo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= @@ -48,8 +48,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83 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.0 h1:WqsoU+5aA9kDypiBzWbLSkESQUA3NDLNvkjTFzipX3I= -github.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.0/go.mod h1:/lGdCgv0e1qrS4ithe2qTU6q23IT8kqZhMlFBQmuNi0= +github.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.1 h1:O4a7qJCbH2bQPzsk7NNIm9/2orkYEH7g4Uerdp0gzps= +github.com/F5Networks/k8s-bigip-ctlr/v2 v2.20.1/go.mod h1:/lGdCgv0e1qrS4ithe2qTU6q23IT8kqZhMlFBQmuNi0= 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= @@ -76,8 +76,8 @@ github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:H 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.32.1 h1:EHs9//xQxXlyltkSFXM+fhO2rTXcWNw6FPKRJ6t+iQQ= -github.com/Yamashou/gqlgenc v0.32.1/go.mod h1:o5SxKt9d3+oUZ2i0V3CW8lHFyunfLR+KcKHubS4zf5E= +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= @@ -114,42 +114,42 @@ github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQ 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.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0= -github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= -github.com/aws/aws-sdk-go-v2/config v1.29.17 h1:jSuiQ5jEe4SAMH6lLRMY9OVC+TqJLP5655pBGjmnjr0= -github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8= -github.com/aws/aws-sdk-go-v2/credentials v1.17.70 h1:ONnH5CM16RTXRkS8Z1qg7/s2eDOhHhaXVd72mmyv4/0= -github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc= -github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.3 h1:xQYRnbQ+ypDMCLiFlLw5cF7Xd6K+oaL7jco2zwIMqTs= -github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.3/go.mod h1:X7RC8FFkx0bjNJRBddd3xdoDaDmNLSxICFdIdJ7asqw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 h1:KAXP9JSHO1vKGCr5f4O6WmlVKLFFXgWYAGoJosorxzU= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 h1:SsytQyTMHMDPspp+spo7XwXTP44aJZZAC7fBV2C5+5s= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 h1:i2vNHQiXUvKhs3quBR6aqlgJaiaexz/aNvdCktW/kAM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= +github.com/aws/aws-sdk-go-v2 v1.36.6 h1:zJqGjVbRdTPojeCGWn5IR5pbJwSQSBh5RWFTQcEQGdU= +github.com/aws/aws-sdk-go-v2 v1.36.6/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= +github.com/aws/aws-sdk-go-v2/config v1.29.18 h1:x4T1GRPnqKV8HMJOMtNktbpQMl3bIsfx8KbqmveUO2I= +github.com/aws/aws-sdk-go-v2/config v1.29.18/go.mod h1:bvz8oXugIsH8K7HLhBv06vDqnFv3NsGDt2Znpk7zmOU= +github.com/aws/aws-sdk-go-v2/credentials v1.17.71 h1:r2w4mQWnrTMJjOyIsZtGp3R3XGY3nqHn8C26C2lQWgA= +github.com/aws/aws-sdk-go-v2/credentials v1.17.71/go.mod h1:E7VF3acIup4GB5ckzbKFrCK0vTvEQxOxgdq4U3vcMCY= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.6 h1:gBfrCR6IwAhmx+oCf9i9FJo1+Cxx5f0In+PaYQbkqbU= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.6/go.mod h1:zAO6MqUum/2yfE/Ig1LPPtzCBudQtrGBaz1gcNzgAoY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 h1:D9ixiWSG4lyUBL2DDNK924Px9V/NBVpML90MHqyTADY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33/go.mod h1:caS/m4DI+cij2paz3rtProRBI4s/+TCiWoaWZuQ9010= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 h1:osMWfm/sC/L4tvEdQ65Gri5ZZDCUpuYJZbTTDrsn4I0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37/go.mod h1:ZV2/1fbjOPr4G4v38G3Ww5TBT4+hmsK45s/rxu1fGy0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 h1:v+X21AvTb2wZ+ycg1gx+orkB/9U6L7AOp93R7qYxsxM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37/go.mod h1:G0uM1kyssELxmJ2VZEfG0q2npObR3BAkF3c1VsfVnfs= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.4 h1:Rv6o9v2AfdEIKoAa7pQpJ5ch9ji2HevFUvGY6ufawlI= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.4/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs= -github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.6 h1:QHaS/SHXfyNycuu4GiWb+AfW5T3bput6X5E3Ai/Q31M= -github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.6/go.mod h1:He/RikglWUczbkV+fkdpcV/3GdL/rTRNVy7VaUiezMo= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.1 h1:UoEWyfuQ/yNOuDENk5nn+AgNCH2Y5yzQEv6YbTyhIV8= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.1/go.mod h1:K1I47BjiTRX00pBxfJLYK80QFRcf6blev2wbjgC5Cyc= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.1 h1:WD2RDt93+IgNvlxEKkx/b3BQrpw5G/YpDHvGXweO5wE= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.1/go.mod h1:8ZWruWnVWtJwjSHEtMWFcI1W6L6PD6i+uKCJ9EiJBbE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 h1:x187MqiHwBGjMGAed8Y8K1VGuCtFvQvXb24r+bwmSdo= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17/go.mod h1:mC9qMbA6e1pwEq6X3zDGtZRXMG2YaElJkbJlMVHLs5I= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4= -github.com/aws/aws-sdk-go-v2/service/route53 v1.52.2 h1:dXHWVVPx2W2fq2PTugj8QXpJ0YTRAGx0KLPKhMBmcsY= -github.com/aws/aws-sdk-go-v2/service/route53 v1.52.2/go.mod h1:wi1naoiPnCQG3cyjsivwPON1ZmQt/EJGxFqXzubBTAw= -github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.7 h1:1eaP4/444jrv04HhJdwTHtgnyxWgxwdLjSYBGq+oMB4= -github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.7/go.mod h1:czoZQabc2chvmV/ak4oGSNR9CbcUw2bef3tatmwtoIA= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 h1:AIRJ3lfb2w/1/8wOOSqYb9fUKGwQbtysJ2H1MofRUPg= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 h1:BpOxT3yhLwSJ77qIY3DoHAQjZsc4HEGfMCE4NGy3uFg= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E= -github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0= -github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.18 h1:QnGWwpTiazs1Y74RwA8VUfAtKuJQbnQ98DBFnSywj0s= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.18/go.mod h1:gWOI6Vb0Bbmsi0Ejvtt3RkwKpdoa/SOYTVUlzqYPRLc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 h1:vvbXsA2TVO80/KT7ZqCbx934dt6PY+vQ8hZpUZ/cpYg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18/go.mod h1:m2JJHledjBGNMsLOF1g9gbAxprzq3KjC8e4lxtn+eWg= +github.com/aws/aws-sdk-go-v2/service/route53 v1.53.1 h1:R3nSX1hguRy6MnknHiepSvqnnL8ansFwK2hidPesAYU= +github.com/aws/aws-sdk-go-v2/service/route53 v1.53.1/go.mod h1:fmSiB4OAghn85lQgk7XN9l9bpFg5Bm1v3HuaXKytPEw= +github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.8 h1:PPQUm3zG6XzctspDTWC6vO3DvP/RZ+04RB11r98yb6E= +github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.8/go.mod h1:C1n2zhotURaNj/BNgdPdhXh/i6V53rI3RmVEaNDakSM= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 h1:rGtWqkQbPk7Bkwuv3NzpE/scwwL9sC1Ul3tn9x83DUI= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.6/go.mod h1:u4ku9OLv4TO4bCPdxf4fA1upaMaJmP9ZijGk3AAOC6Q= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 h1:OV/pxyXh+eMA0TExHEC4jyWdumLxNbzz1P0zJoezkJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4/go.mod h1:8Mm5VGYwtm+r305FfPSuc+aFkrypeylGYhFim6XEPoc= +github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 h1:aUrLQwJfZtwv3/ZNG2xRtEen+NqI3iesuacjP51Mv1s= +github.com/aws/aws-sdk-go-v2/service/sts v1.34.1/go.mod h1:3wFBZKoWnX3r+Sm7in79i54fBmNfwhdNdQuscCw7QIk= github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= @@ -172,20 +172,22 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0Bsq 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.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +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.6.1 h1:PFOh7rBU0vmj7LTDIv3z7l9uXG4SZyyzScCl3wyTFSc= -github.com/civo/civogo v0.6.1/go.mod h1:LaEbkszc+9nXSh4YNG0sYXFGYqdQFmXXzQg0gESs2hc= +github.com/civo/civogo v0.6.2 h1:tQegf+coNxIKhLjOo5bwAV04CPSk6ealSod55XHb7cw= +github.com/civo/civogo v0.6.2/go.mod h1:LaEbkszc+9nXSh4YNG0sYXFGYqdQFmXXzQg0gESs2hc= 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 v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= +github.com/cloudflare/cloudflare-go/v4 v4.6.0 h1:ZaWwXjHFR5NoY8UEf4QFY0g3KTi72kqqEXpajV610/o= +github.com/cloudflare/cloudflare-go/v4 v4.6.0/go.mod h1:XcYpLe7Mf6FN87kXzEWVnJ6z+vskW/k6eUqgqfhFE9k= github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 h1:rdRS5BT13Iae9ssvcslol66gfOOXjaLYwqerEn/cl9s= github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381/go.mod h1:e5+USP2j8Le2M0Jo3qKPFnNhuo1wueU4nWHCXBOfQ14= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -250,8 +252,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/digitalocean/godo v1.155.0 h1:+Y09Nz1TTXFSq5fdgSpqvCKfEpN35FU9WIOMuEuCwgg= -github.com/digitalocean/godo v1.155.0/go.mod h1:tYeiWY5ZXVpU48YaFv0M5irUFHXGorZpDNm7zzdWMzM= +github.com/digitalocean/godo v1.160.0 h1:3Wa6mOzv1m5DZQDANAk8u6v4DIUm5x2i4tZ7ke28lhs= +github.com/digitalocean/godo v1.160.0/go.mod h1:tYeiWY5ZXVpU48YaFv0M5irUFHXGorZpDNm7zzdWMzM= 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= @@ -501,8 +503,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= -github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= 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= @@ -674,10 +676,8 @@ 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/linki/instrumented_http v0.3.0 h1:dsN92+mXpfZtjJraartcQ99jnuw7fqsnPDjr85ma2dA= -github.com/linki/instrumented_http v0.3.0/go.mod h1:pjYbItoegfuVi2GUOMhEqzvm/SJKuEL3H0tc8QRLRFk= -github.com/linode/linodego v1.52.1 h1:HJ1cz1n9n3chRP9UrtqmP91+xTi0Q5l+H/4z4tpkwgQ= -github.com/linode/linodego v1.52.1/go.mod h1:zEN2sX+cSdp67EuRY1HJiyuLujoa7HqvVwNEcJv3iXw= +github.com/linode/linodego v1.53.0 h1:UWr7bUUVMtcfsuapC+6blm6+jJLPd7Tf9MZUpdOERnI= +github.com/linode/linodego v1.53.0/go.mod h1:bI949fZaVchjWyKIA08hNyvAcV6BAS+PM2op3p7PAWA= 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= @@ -723,8 +723,8 @@ github.com/mholt/archiver/v3 v3.3.0/go.mod h1:YnQtqsp+94Rwd0D/rk5cnLrxusUBUXg+08 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.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= -github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= +github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0= +github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= 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= @@ -819,8 +819,8 @@ github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxS 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.94.0 h1:6Vbv7oCb8plv7wNnx0cI+6kBQ7RUpZAvj3tQaHDXULo= -github.com/oracle/oci-go-sdk/v65 v65.94.0/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA= +github.com/oracle/oci-go-sdk/v65 v65.96.0 h1:ew0WavsB6N/I6etYCC160cD5qDXbek/1xZgujqTzork= +github.com/oracle/oci-go-sdk/v65 v65.96.0/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA= 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/oxtoacart/bpool v0.0.0-20150712133111-4e1c5567d7c2 h1:CXwSGu/LYmbjEab5aMCs5usQRVBGThelUKBNnoSOuso= @@ -882,8 +882,8 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 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.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= -github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 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= @@ -907,8 +907,8 @@ github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE 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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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= @@ -916,8 +916,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD 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.33 h1:KhF0WejiUTDbL5X55nXowP7zNopwpowa6qaMAWyIE+0= -github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33/go.mod h1:792k1RTU+5JeMXm35/e2Wgp71qPH/DmDoZrRc+EFZDk= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.34 h1:48+VFHsyVcAHIN2v1Ao9v1/RkjJS5AwctFucBrfYNIA= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.34/go.mod h1:zFWiHphneiey3s8HOtAEnGrRlWivNaxW5T6d5Xfco7g= 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= @@ -990,7 +990,17 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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.0 h1:Aejfvh8rSp8Mj2GX/RpdBjMCv+Iy/DmgfNgczPDP550= @@ -1012,8 +1022,8 @@ github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtX 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.25 h1:FmWtFEa+invTIzWlWK6Vk7BVEZU/97QBzeI8Z1JjGt8= -github.com/vektah/gqlparser/v2 v2.5.25/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +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= @@ -1040,12 +1050,12 @@ github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wK 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.1 h1:yJ9WlDih9HT457QPuHt/TH/XtsdN2tubyxyQHSHPsEo= -go.etcd.io/etcd/api/v3 v3.6.1/go.mod h1:lnfuqoGsXMlZdTJlact3IB56o3bWp1DIlXPIGKRArto= -go.etcd.io/etcd/client/pkg/v3 v3.6.1 h1:CxDVv8ggphmamrXM4Of8aCC8QHzDM4tGcVr9p2BSoGk= -go.etcd.io/etcd/client/pkg/v3 v3.6.1/go.mod h1:aTkCp+6ixcVTZmrJGa7/Mc5nMNs59PEgBbq+HCmWyMc= -go.etcd.io/etcd/client/v3 v3.6.1 h1:KelkcizJGsskUXlsxjVrSmINvMMga0VWwFF0tSPGEP0= -go.etcd.io/etcd/client/v3 v3.6.1/go.mod h1:fCbPUdjWNLfx1A6ATo9syUmFVxqHH9bCnPLBZmnLmMY= +go.etcd.io/etcd/api/v3 v3.6.4 h1:7F6N7toCKcV72QmoUKa23yYLiiljMrT4xCeBL9BmXdo= +go.etcd.io/etcd/api/v3 v3.6.4/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk= +go.etcd.io/etcd/client/pkg/v3 v3.6.4 h1:9HBYrjppeOfFjBjaMTRxT3R7xT0GLK8EJMVC4xg6ok0= +go.etcd.io/etcd/client/pkg/v3 v3.6.4/go.mod h1:sbdzr2cl3HzVmxNw//PH7aLGVtY4QySjQFuaCgcRFAI= +go.etcd.io/etcd/client/v3 v3.6.4 h1:YOMrCfMhRzY8NgtzUsHl8hC2EBSnuqbR3dh84Uryl7A= +go.etcd.io/etcd/client/v3 v3.6.4/go.mod h1:jaNNHCyg2FdALyKWnd7hxZXZxZANb0+KGY+YQaEMISo= 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= @@ -1115,8 +1125,8 @@ golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 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= @@ -1184,8 +1194,8 @@ golang.org/x/net v0.0.0-20220725212005-46097bf591d3/go.mod h1:AaygXjzTFtRAg2ttMY golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190130055435-99b60b757ec1/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1203,8 +1213,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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= @@ -1261,8 +1271,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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= @@ -1270,8 +1280,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 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= @@ -1282,8 +1292,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 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= @@ -1326,8 +1336,8 @@ golang.org/x/tools v0.0.0-20210114065538-d78b04bdf963/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 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= @@ -1340,8 +1350,8 @@ gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZ 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.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo= -google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/api v0.243.0 h1:sw+ESIJ4BVnlJcWu9S+p2Z6Qq1PjG77T8IJ1xtp4jZQ= +google.golang.org/api v0.243.0/go.mod h1:GE4QtYfaybx1KmeHMdBnNnyLzBZCVihGBXAmJu/uUr8= 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= @@ -1355,12 +1365,12 @@ google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRn 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-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78= -google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0= -google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 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= @@ -1449,16 +1459,16 @@ istio.io/client-go v1.26.2/go.mod h1:eAImguSJPdaDiSSS2CEsywNHE8WWfqd3WfS18Rj8ynI 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.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= -k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= +k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= +k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= 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/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.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= -k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= +k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= 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= @@ -1467,8 +1477,8 @@ 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.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= -k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= +k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA= +k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg= 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= diff --git a/internal/OWNERS b/internal/OWNERS new file mode 100644 index 000000000..b7cb86a46 --- /dev/null +++ b/internal/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- internal diff --git a/internal/gen/docs/metrics/main_test.go b/internal/gen/docs/metrics/main_test.go index a6d1efc27..bea1d75e8 100644 --- a/internal/gen/docs/metrics/main_test.go +++ b/internal/gen/docs/metrics/main_test.go @@ -37,7 +37,7 @@ func TestComputeMetrics(t *testing.T) { t.Errorf("Expected not empty metrics registry, got %d", len(reg.Metrics)) } - assert.Len(t, reg.Metrics, 19) + assert.Len(t, reg.Metrics, 20) } func TestGenerateMarkdownTableRenderer(t *testing.T) { diff --git a/internal/idna/idna.go b/internal/idna/idna.go new file mode 100644 index 000000000..9290e8a51 --- /dev/null +++ b/internal/idna/idna.go @@ -0,0 +1,29 @@ +/* +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 ( + "golang.org/x/net/idna" +) + +var ( + Profile = idna.New( + idna.MapForLookup(), + idna.Transitional(true), + idna.StrictDomainName(false), + ) +) diff --git a/internal/idna/idna_test.go b/internal/idna/idna_test.go new file mode 100644 index 000000000..f3ae93c44 --- /dev/null +++ b/internal/idna/idna_test.go @@ -0,0 +1,59 @@ +/* +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) + }) + } +} diff --git a/kustomize/OWNERS b/kustomize/OWNERS new file mode 100644 index 000000000..0846b3234 --- /dev/null +++ b/kustomize/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- kustomize diff --git a/pkg/apis/OWNERS b/pkg/apis/OWNERS new file mode 100644 index 000000000..16088024f --- /dev/null +++ b/pkg/apis/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- apis diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index fedfb8bd7..c4df4e35f 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -115,7 +115,6 @@ type Config struct { CloudflareCustomHostnamesCertificateAuthority string CloudflareRegionalServices bool CloudflareRegionKey string - CloudflareRecordComment string CoreDNSPrefix string AkamaiServiceConsumerDomain string AkamaiClientToken string @@ -209,7 +208,7 @@ type Config struct { WebhookProviderReadTimeout time.Duration WebhookProviderWriteTimeout time.Duration WebhookServer bool - TraefikDisableLegacy bool + TraefikEnableLegacy bool TraefikDisableNew bool NAT64Networks []string ExcludeUnschedulable bool @@ -359,7 +358,7 @@ var defaultConfig = &Config{ TLSCA: "", TLSClientCert: "", TLSClientCertKey: "", - TraefikDisableLegacy: false, + TraefikEnableLegacy: false, TraefikDisableNew: false, TransIPAccountName: "", TransIPPrivateKeyFile: "", @@ -486,7 +485,7 @@ func App(cfg *Config) *kingpin.Application { app.Flag("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)").Default(defaultConfig.ServiceTypeFilter...).StringsVar(&cfg.ServiceTypeFilter) app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, pod, fake, connector, gateway-httproute, gateway-grpcroute, gateway-tlsroute, gateway-tcproute, gateway-udproute, istio-gateway, istio-virtualservice, cloudfoundry, contour-httpproxy, gloo-proxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host, kong-tcpingress, f5-virtualserver, f5-transportserver, traefik-proxy)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "pod", "gateway-httproute", "gateway-grpcroute", "gateway-tlsroute", "gateway-tcproute", "gateway-udproute", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-httpproxy", "gloo-proxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host", "kong-tcpingress", "f5-virtualserver", "f5-transportserver", "traefik-proxy") app.Flag("target-net-filter", "Limit possible targets by a net filter; specify multiple times for multiple possible nets (optional)").StringsVar(&cfg.TargetNetFilter) - app.Flag("traefik-disable-legacy", "Disable listeners on Resources under the traefik.containo.us API Group").Default(strconv.FormatBool(defaultConfig.TraefikDisableLegacy)).BoolVar(&cfg.TraefikDisableLegacy) + app.Flag("traefik-enable-legacy", "Enable legacy listeners on Resources under the traefik.containo.us API Group").Default(strconv.FormatBool(defaultConfig.TraefikEnableLegacy)).BoolVar(&cfg.TraefikEnableLegacy) app.Flag("traefik-disable-new", "Disable listeners on Resources under the traefik.io API Group").Default(strconv.FormatBool(defaultConfig.TraefikDisableNew)).BoolVar(&cfg.TraefikDisableNew) // Flags related to providers @@ -505,7 +504,7 @@ func App(cfg *Config) *kingpin.Application { app.Flag("google-zone-visibility", "When using the Google provider, filter for zones with this visibility (optional, options: public, private)").Default(defaultConfig.GoogleZoneVisibility).EnumVar(&cfg.GoogleZoneVisibility, "", "public", "private") app.Flag("alibaba-cloud-config-file", "When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud)").Default(defaultConfig.AlibabaCloudConfigFile).StringVar(&cfg.AlibabaCloudConfigFile) app.Flag("alibaba-cloud-zone-type", "When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AlibabaCloudZoneType).EnumVar(&cfg.AlibabaCloudZoneType, "", "public", "private") - app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private") + app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, default: any, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private") app.Flag("aws-zone-tags", "When using the AWS provider, filter for zones with these tags").Default("").StringsVar(&cfg.AWSZoneTagFilter) app.Flag("aws-profile", "When using the AWS provider, name of the profile to use").Default("").StringsVar(&cfg.AWSProfiles) app.Flag("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)").Default(defaultConfig.AWSAssumeRole).StringVar(&cfg.AWSAssumeRole) @@ -535,7 +534,7 @@ func App(cfg *Config) *kingpin.Application { app.Flag("cloudflare-dns-records-per-page", "When using the Cloudflare provider, specify how many DNS records listed per page, max possible 5,000 (default: 100)").Default(strconv.Itoa(defaultConfig.CloudflareDNSRecordsPerPage)).IntVar(&cfg.CloudflareDNSRecordsPerPage) app.Flag("cloudflare-regional-services", "When using the Cloudflare provider, specify if Regional Services feature will be used (default: disabled)").Default(strconv.FormatBool(defaultConfig.CloudflareRegionalServices)).BoolVar(&cfg.CloudflareRegionalServices) app.Flag("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)").StringVar(&cfg.CloudflareRegionKey) - app.Flag("cloudflare-record-comment", "When using the Cloudflare provider, specify the comment for the DNS records (default: '')").Default("").StringVar(&cfg.CloudflareRecordComment) + app.Flag("cloudflare-record-comment", "When using the Cloudflare provider, specify the comment for the DNS records (default: '')").Default("").StringVar(&cfg.CloudflareDNSRecordsComment) app.Flag("coredns-prefix", "When using the CoreDNS provider, specify the prefix name").Default(defaultConfig.CoreDNSPrefix).StringVar(&cfg.CoreDNSPrefix) app.Flag("akamai-serviceconsumerdomain", "When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiServiceConsumerDomain).StringVar(&cfg.AkamaiServiceConsumerDomain) diff --git a/pkg/http/http.go b/pkg/http/http.go new file mode 100644 index 000000000..b6069f1a5 --- /dev/null +++ b/pkg/http/http.go @@ -0,0 +1,97 @@ +/* +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" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" +) + +var ( + requestDuration = prometheus.NewSummaryVec( + 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{"scheme", "host", "path", "method", "status"}, + ) +) + +func init() { + prometheus.MustRegister(requestDuration) +} + +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) + } + + labels := prometheus.Labels{ + "scheme": req.URL.Scheme, + "host": req.URL.Host, + "path": pathProcessor(req.URL.Path), + "method": req.Method, + "status": status, + } + requestDuration.With(labels).Observe(time.Since(start).Seconds()) + 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} +} + +func pathProcessor(path string) string { + parts := strings.Split(path, "/") + return parts[len(parts)-1] +} diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go new file mode 100644 index 000000000..cd17f3873 --- /dev/null +++ b/pkg/http/http_test.go @@ -0,0 +1,81 @@ +/* +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" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +type dummyTransport struct{} + +func (d *dummyTransport) RoundTrip(req *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 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)) + }) + } +} diff --git a/pkg/metrics/OWNERS b/pkg/metrics/OWNERS new file mode 100644 index 000000000..3191b8289 --- /dev/null +++ b/pkg/metrics/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- metrics diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index d65e172ea..73e6ea5d1 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -17,25 +17,44 @@ limitations under the License. package metrics import ( - "runtime" + "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{ - "version": cfg.Version, - "arch": runtime.GOARCH, - "go_version": runtime.Version(), - }, + prometheus.Labels{}, prometheus.DefaultRegisterer) return &MetricRegistry{ Registerer: reg, @@ -54,7 +73,7 @@ func NewMetricsRegister() *MetricRegistry { // } func (m *MetricRegistry) MustRegister(cs IMetric) { switch v := cs.(type) { - case CounterMetric, GaugeMetric, CounterVecMetric, GaugeVecMetric: + case CounterMetric, GaugeMetric, CounterVecMetric, GaugeVecMetric, GaugeFuncMetric: if _, exists := m.mName[cs.Get().FQDN]; exists { return } else { @@ -70,6 +89,8 @@ func (m *MetricRegistry) MustRegister(cs IMetric) { 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: diff --git a/pkg/metrics/models.go b/pkg/metrics/models.go index 1aaf708e7..8c8fb4d4f 100644 --- a/pkg/metrics/models.go +++ b/pkg/metrics/models.go @@ -88,6 +88,7 @@ func (g GaugeVecMetric) SetWithLabels(value float64, lvs ...string) { } func NewGaugeWithOpts(opts prometheus.GaugeOpts) GaugeMetric { + opts.Namespace = Namespace return GaugeMetric{ Metric: Metric{ Type: "gauge", @@ -104,6 +105,7 @@ func NewGaugeWithOpts(opts prometheus.GaugeOpts) GaugeMetric { // 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", @@ -118,6 +120,7 @@ func NewGaugedVectorOpts(opts prometheus.GaugeOpts, labelNames []string) GaugeVe } func NewCounterWithOpts(opts prometheus.CounterOpts) CounterMetric { + opts.Namespace = Namespace return CounterMetric{ Metric: Metric{ Type: "counter", @@ -132,6 +135,7 @@ func NewCounterWithOpts(opts prometheus.CounterOpts) CounterMetric { } func NewCounterVecWithOpts(opts prometheus.CounterOpts, labelNames []string) CounterVecMetric { + opts.Namespace = Namespace return CounterVecMetric{ Metric: Metric{ Type: "counter", @@ -144,3 +148,31 @@ func NewCounterVecWithOpts(opts prometheus.CounterOpts, labelNames []string) Cou 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 }), + } +} diff --git a/pkg/metrics/models_test.go b/pkg/metrics/models_test.go index 70f50ea1a..738f9f6ed 100644 --- a/pkg/metrics/models_test.go +++ b/pkg/metrics/models_test.go @@ -17,18 +17,17 @@ limitations under the License. package metrics import ( + "reflect" "testing" - dto "github.com/prometheus/client_model/go" - "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/assert" ) func TestNewGaugeWithOpts(t *testing.T) { opts := prometheus.GaugeOpts{ Name: "test_gauge", - Namespace: "test_namespace", Subsystem: "test_subsystem", Help: "This is a test gauge", } @@ -37,7 +36,7 @@ func TestNewGaugeWithOpts(t *testing.T) { assert.Equal(t, "gauge", gaugeMetric.Type) assert.Equal(t, "test_gauge", gaugeMetric.Name) - assert.Equal(t, "test_namespace", gaugeMetric.Namespace) + 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) @@ -47,7 +46,6 @@ func TestNewGaugeWithOpts(t *testing.T) { func TestNewCounterWithOpts(t *testing.T) { opts := prometheus.CounterOpts{ Name: "test_counter", - Namespace: "test_namespace", Subsystem: "test_subsystem", Help: "This is a test counter", } @@ -56,7 +54,7 @@ func TestNewCounterWithOpts(t *testing.T) { assert.Equal(t, "counter", counterMetric.Type) assert.Equal(t, "test_counter", counterMetric.Name) - assert.Equal(t, "test_namespace", counterMetric.Namespace) + 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) @@ -77,7 +75,7 @@ func TestNewCounterVecWithOpts(t *testing.T) { assert.Equal(t, "counter", counterVecMetric.Type) assert.Equal(t, "test_counter_vec", counterVecMetric.Name) - assert.Equal(t, "test_namespace", counterVecMetric.Namespace) + 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) @@ -113,3 +111,20 @@ func TestGaugeV_SetWithLabels(t *testing.T) { assert.Len(t, m.Label, 2) } + +func TestNewBuildInfoCollector(t *testing.T) { + metric := NewGaugeFuncMetric(prometheus.GaugeOpts{ + Namespace: Namespace, + Name: "build_info", + ConstLabels: prometheus.Labels{ + "version": "0.0.1", + "goversion": "1.24", + "arch": "arm64", + }, + }) + + desc := metric.GaugeFunc.Desc() + + assert.Equal(t, "external_dns_build_info", reflect.ValueOf(desc).Elem().FieldByName("fqName").String()) + assert.Contains(t, desc.String(), "version=\"0.0.1\"") +} diff --git a/pkg/rfc2317/OWNERS b/pkg/rfc2317/OWNERS new file mode 100644 index 000000000..593666f31 --- /dev/null +++ b/pkg/rfc2317/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- rfc2317 diff --git a/pkg/rfc2317/arpa.go b/pkg/rfc2317/arpa.go index f11bac100..a79716bac 100644 --- a/pkg/rfc2317/arpa.go +++ b/pkg/rfc2317/arpa.go @@ -98,7 +98,7 @@ func CidrToInAddr(cidr string) (string, error) { // 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) (arpa string, err error) { +func reverseaddr(addr string) (string, error) { ip := net.ParseIP(addr) if ip == nil { return "", &net.DNSError{Err: "unrecognized address", Name: addr} diff --git a/pkg/tlsutils/OWNERS b/pkg/tlsutils/OWNERS new file mode 100644 index 000000000..dc11b01c8 --- /dev/null +++ b/pkg/tlsutils/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- tls diff --git a/plan/OWNERS b/plan/OWNERS new file mode 100644 index 000000000..f660de545 --- /dev/null +++ b/plan/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- plan diff --git a/plan/plan.go b/plan/plan.go index 699dcdc5c..cf7db8858 100644 --- a/plan/plan.go +++ b/plan/plan.go @@ -18,13 +18,14 @@ package plan import ( "fmt" + "slices" "strings" "github.com/google/go-cmp/cmp" log "github.com/sirupsen/logrus" - "golang.org/x/net/idna" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/internal/idna" ) // PropertyComparator is used in Plan for comparing the previous and current custom annotations. @@ -342,7 +343,7 @@ func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.Ma // 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 := idna.Lookup.ToASCII(strings.TrimSpace(dnsName)) + s, err := idna.Profile.ToASCII(strings.TrimSpace(dnsName)) if err != nil { log.Warnf(`Got error while parsing DNSName %s: %v`, dnsName, err) } @@ -353,15 +354,8 @@ func normalizeDNSName(dnsName string) string { } func IsManagedRecord(record string, managedRecords, excludeRecords []string) bool { - for _, r := range excludeRecords { - if record == r { - return false - } + if slices.Contains(excludeRecords, record) { + return false } - for _, r := range managedRecords { - if record == r { - return true - } - } - return false + return slices.Contains(managedRecords, record) } diff --git a/plan/plan_test.go b/plan/plan_test.go index 5dde2c9c9..10ce5a992 100644 --- a/plan/plan_test.go +++ b/plan/plan_test.go @@ -1028,7 +1028,7 @@ func validateEntries(t *testing.T, entries, expected []*endpoint.Endpoint) { } } -func TestNormalizeDNSName(t *testing.T) { +func TestNormalizeDNSName(tt *testing.T) { records := []struct { dnsName string expect string @@ -1061,6 +1061,18 @@ func TestNormalizeDNSName(t *testing.T) { "foo.com.", "foo.com.", }, + { + "_foo.com.", + "_foo.com.", + }, + { + "\u005Ffoo.com.", + "_foo.com.", + }, + { + ".foo.com.", + ".foo.com.", + }, { "foo123.COM", "foo123.com.", @@ -1097,10 +1109,20 @@ func TestNormalizeDNSName(t *testing.T) { "xn--nordic--w1a.kitty😸.com.", "xn--nordic--w1a.xn--kitty-pd34d.com.", }, + { + "*.example.com.", + "*.example.com.", + }, + { + "*.example.com", + "*.example.com.", + }, } for _, r := range records { - gotName := normalizeDNSName(r.dnsName) - assert.Equal(t, r.expect, gotName) + tt.Run(r.dnsName, func(t *testing.T) { + gotName := normalizeDNSName(r.dnsName) + assert.Equal(t, r.expect, gotName) + }) } } diff --git a/provider/akamai/akamai.go b/provider/akamai/akamai.go index 4066fbcc1..b513150bf 100644 --- a/provider/akamai/akamai.go +++ b/provider/akamai/akamai.go @@ -40,7 +40,7 @@ const ( maxInt = int(maxUint >> 1) ) -// edgeDNSClient is a proxy interface of the Akamai edgegrid configdns-v2 package that can be stubbed for testing. +// 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) @@ -208,7 +208,8 @@ func (p AkamaiProvider) fetchZones() (akamaiZones, error) { } // Records returns the list of records in a given zone. -func (p AkamaiProvider) Records(context.Context) (endpoints []*endpoint.Endpoint, err error) { +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()) diff --git a/provider/alibabacloud/alibaba_cloud.go b/provider/alibabacloud/alibaba_cloud.go index 4cb41d1d1..fcb750f4f 100644 --- a/provider/alibabacloud/alibaba_cloud.go +++ b/provider/alibabacloud/alibaba_cloud.go @@ -29,7 +29,7 @@ import ( "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" "github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz" "github.com/denverdino/aliyungo/metadata" - yaml "github.com/goccy/go-yaml" + "github.com/goccy/go-yaml" log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" @@ -49,22 +49,22 @@ const ( // 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) (response *alidns.AddDomainRecordResponse, err error) - DeleteDomainRecord(request *alidns.DeleteDomainRecordRequest) (response *alidns.DeleteDomainRecordResponse, err error) - UpdateDomainRecord(request *alidns.UpdateDomainRecordRequest) (response *alidns.UpdateDomainRecordResponse, err error) - DescribeDomainRecords(request *alidns.DescribeDomainRecordsRequest) (response *alidns.DescribeDomainRecordsResponse, err error) - DescribeDomains(request *alidns.DescribeDomainsRequest) (response *alidns.DescribeDomainsResponse, err error) + 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) (response *pvtz.AddZoneRecordResponse, err error) - DeleteZoneRecord(request *pvtz.DeleteZoneRecordRequest) (response *pvtz.DeleteZoneRecordResponse, err error) - UpdateZoneRecord(request *pvtz.UpdateZoneRecordRequest) (response *pvtz.UpdateZoneRecordResponse, err error) - DescribeZoneRecords(request *pvtz.DescribeZoneRecordsRequest) (response *pvtz.DescribeZoneRecordsResponse, err error) - DescribeZones(request *pvtz.DescribeZonesRequest) (response *pvtz.DescribeZonesResponse, err error) - DescribeZoneInfo(request *pvtz.DescribeZoneInfoRequest) (response *pvtz.DescribeZoneInfoResponse, err error) + 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. @@ -284,19 +284,18 @@ func (p *AlibabaCloudProvider) refreshStsToken(sleepTime time.Duration) { // Records gets the current records. // // Returns the current records or an error if the operation failed. -func (p *AlibabaCloudProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, err error) { +func (p *AlibabaCloudProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { if p.privateZone { - endpoints, err = p.privateZoneRecords() + return p.privateZoneRecords() } else { - endpoints, err = p.recordsForDNS() + return p.recordsForDNS() } - return endpoints, err } // ApplyChanges applies the given changes. // // Returns nil if the operation was successful or an error if the operation failed. -func (p *AlibabaCloudProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { +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 @@ -318,11 +317,12 @@ func (p *AlibabaCloudProvider) getDNSName(rr, domain string) string { // recordsForDNS gets the current records. // // Returns the current records or an error if the operation failed. -func (p *AlibabaCloudProvider) recordsForDNS() (endpoints []*endpoint.Endpoint, _ error) { +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 @@ -360,8 +360,8 @@ func (p *AlibabaCloudProvider) getRecordKeyByEndpoint(endpoint *endpoint.Endpoin return endpoint.RecordType + ":" + endpoint.DNSName } -func (p *AlibabaCloudProvider) groupRecords(records []alidns.Record) (endpointMap map[string][]alidns.Record) { - endpointMap = make(map[string][]alidns.Record) +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) @@ -675,7 +675,7 @@ func (p *AlibabaCloudProvider) updateRecords(recordMap map[string][]alidns.Recor return nil } -func (p *AlibabaCloudProvider) splitDNSName(dnsName string, hostedZoneDomains []string) (rr string, domain string) { +func (p *AlibabaCloudProvider) splitDNSName(dnsName string, hostedZoneDomains []string) (string, string) { name := strings.TrimSuffix(dnsName, ".") // sort zones by dot count; make sure subdomains sort earlier @@ -683,6 +683,8 @@ func (p *AlibabaCloudProvider) splitDNSName(dnsName string, hostedZoneDomains [] 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] @@ -819,8 +821,8 @@ func (p *AlibabaCloudProvider) getPrivateZones() (map[string]*alibabaPrivateZone return result, nil } -func (p *AlibabaCloudProvider) groupPrivateZoneRecords(zone *alibabaPrivateZone) (endpointMap map[string][]pvtz.Record) { - endpointMap = make(map[string][]pvtz.Record) +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 @@ -834,12 +836,14 @@ func (p *AlibabaCloudProvider) groupPrivateZoneRecords(zone *alibabaPrivateZone) // recordsForPrivateZone gets the current records. // // Returns the current records or an error if the operation failed. -func (p *AlibabaCloudProvider) privateZoneRecords() (endpoints []*endpoint.Endpoint, _ error) { +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 { @@ -908,7 +912,7 @@ func (p *AlibabaCloudProvider) createPrivateZoneRecord(zones map[string]*alibaba func (p *AlibabaCloudProvider) createPrivateZoneRecords(zones map[string]*alibabaPrivateZone, endpoints []*endpoint.Endpoint) error { for _, endpoint := range endpoints { for _, target := range endpoint.Targets { - p.createPrivateZoneRecord(zones, endpoint, target) + _ = p.createPrivateZoneRecord(zones, endpoint, target) } } return nil diff --git a/provider/alibabacloud/alibaba_cloud_test.go b/provider/alibabacloud/alibaba_cloud_test.go index 2bf7392bc..9278e1514 100644 --- a/provider/alibabacloud/alibaba_cloud_test.go +++ b/provider/alibabacloud/alibaba_cloud_test.go @@ -55,7 +55,7 @@ func NewMockAlibabaCloudDNSAPI() *MockAlibabaCloudDNSAPI { return &api } -func (m *MockAlibabaCloudDNSAPI) AddDomainRecord(request *alidns.AddDomainRecordRequest) (response *alidns.AddDomainRecordResponse, err error) { +func (m *MockAlibabaCloudDNSAPI) AddDomainRecord(request *alidns.AddDomainRecordRequest) (*alidns.AddDomainRecordResponse, error) { ttl, _ := request.TTL.GetValue() m.records = append(m.records, alidns.Record{ RecordId: "3", @@ -65,11 +65,10 @@ func (m *MockAlibabaCloudDNSAPI) AddDomainRecord(request *alidns.AddDomainRecord RR: request.RR, Value: request.Value, }) - response = alidns.CreateAddDomainRecordResponse() - return response, nil + return alidns.CreateAddDomainRecordResponse(), nil } -func (m *MockAlibabaCloudDNSAPI) DeleteDomainRecord(request *alidns.DeleteDomainRecordRequest) (response *alidns.DeleteDomainRecordResponse, err error) { +func (m *MockAlibabaCloudDNSAPI) DeleteDomainRecord(request *alidns.DeleteDomainRecordRequest) (*alidns.DeleteDomainRecordResponse, error) { var result []alidns.Record for _, record := range m.records { if record.RecordId != request.RecordId { @@ -77,24 +76,24 @@ func (m *MockAlibabaCloudDNSAPI) DeleteDomainRecord(request *alidns.DeleteDomain } } m.records = result - response = alidns.CreateDeleteDomainRecordResponse() + response := alidns.CreateDeleteDomainRecordResponse() response.RecordId = request.RecordId return response, nil } -func (m *MockAlibabaCloudDNSAPI) UpdateDomainRecord(request *alidns.UpdateDomainRecordRequest) (response *alidns.UpdateDomainRecordResponse, err error) { +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 := alidns.CreateUpdateDomainRecordResponse() response.RecordId = request.RecordId return response, nil } -func (m *MockAlibabaCloudDNSAPI) DescribeDomains(request *alidns.DescribeDomainsRequest) (response *alidns.DescribeDomainsResponse, err error) { +func (m *MockAlibabaCloudDNSAPI) DescribeDomains(request *alidns.DescribeDomainsRequest) (*alidns.DescribeDomainsResponse, error) { var result alidns.DomainsInDescribeDomains for _, record := range m.records { domain := alidns.Domain{} @@ -103,19 +102,19 @@ func (m *MockAlibabaCloudDNSAPI) DescribeDomains(request *alidns.DescribeDomains DomainName: domain.DomainName, }) } - response = alidns.CreateDescribeDomainsResponse() + response := alidns.CreateDescribeDomainsResponse() response.Domains = result return response, nil } -func (m *MockAlibabaCloudDNSAPI) DescribeDomainRecords(request *alidns.DescribeDomainRecordsRequest) (response *alidns.DescribeDomainRecordsResponse, err error) { +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 := alidns.CreateDescribeDomainRecordsResponse() response.DomainRecords.Record = result return response, nil } @@ -158,7 +157,7 @@ func NewMockAlibabaCloudPrivateZoneAPI() *MockAlibabaCloudPrivateZoneAPI { return &api } -func (m *MockAlibabaCloudPrivateZoneAPI) AddZoneRecord(request *pvtz.AddZoneRecordRequest) (response *pvtz.AddZoneRecordResponse, err error) { +func (m *MockAlibabaCloudPrivateZoneAPI) AddZoneRecord(request *pvtz.AddZoneRecordRequest) (*pvtz.AddZoneRecordResponse, error) { ttl, _ := request.Ttl.GetValue() m.records = append(m.records, pvtz.Record{ RecordId: 3, @@ -167,11 +166,10 @@ func (m *MockAlibabaCloudPrivateZoneAPI) AddZoneRecord(request *pvtz.AddZoneReco Rr: request.Rr, Value: request.Value, }) - response = pvtz.CreateAddZoneRecordResponse() - return response, nil + return pvtz.CreateAddZoneRecordResponse(), nil } -func (m *MockAlibabaCloudPrivateZoneAPI) DeleteZoneRecord(request *pvtz.DeleteZoneRecordRequest) (response *pvtz.DeleteZoneRecordResponse, err error) { +func (m *MockAlibabaCloudPrivateZoneAPI) DeleteZoneRecord(request *pvtz.DeleteZoneRecordRequest) (*pvtz.DeleteZoneRecordResponse, error) { recordID, _ := request.RecordId.GetValue64() var result []pvtz.Record @@ -181,11 +179,10 @@ func (m *MockAlibabaCloudPrivateZoneAPI) DeleteZoneRecord(request *pvtz.DeleteZo } } m.records = result - response = pvtz.CreateDeleteZoneRecordResponse() - return response, nil + return pvtz.CreateDeleteZoneRecordResponse(), nil } -func (m *MockAlibabaCloudPrivateZoneAPI) UpdateZoneRecord(request *pvtz.UpdateZoneRecordRequest) (response *pvtz.UpdateZoneRecordResponse, err error) { +func (m *MockAlibabaCloudPrivateZoneAPI) UpdateZoneRecord(request *pvtz.UpdateZoneRecordRequest) (*pvtz.UpdateZoneRecordResponse, error) { recordID, _ := request.RecordId.GetValue64() ttl, _ := request.Ttl.GetValue() for i := range m.records { @@ -193,24 +190,23 @@ func (m *MockAlibabaCloudPrivateZoneAPI) UpdateZoneRecord(request *pvtz.UpdateZo m.records[i].Ttl = ttl } } - response = pvtz.CreateUpdateZoneRecordResponse() - return response, nil + return pvtz.CreateUpdateZoneRecordResponse(), nil } -func (m *MockAlibabaCloudPrivateZoneAPI) DescribeZoneRecords(request *pvtz.DescribeZoneRecordsRequest) (response *pvtz.DescribeZoneRecordsResponse, err error) { - response = pvtz.CreateDescribeZoneRecordsResponse() +func (m *MockAlibabaCloudPrivateZoneAPI) DescribeZoneRecords(request *pvtz.DescribeZoneRecordsRequest) (*pvtz.DescribeZoneRecordsResponse, error) { + response := pvtz.CreateDescribeZoneRecordsResponse() response.Records.Record = append(response.Records.Record, m.records...) return response, nil } -func (m *MockAlibabaCloudPrivateZoneAPI) DescribeZones(request *pvtz.DescribeZonesRequest) (response *pvtz.DescribeZonesResponse, err error) { - response = pvtz.CreateDescribeZonesResponse() +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(request *pvtz.DescribeZoneInfoRequest) (response *pvtz.DescribeZoneInfoResponse, err error) { - response = pvtz.CreateDescribeZoneInfoResponse() +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))} @@ -336,7 +332,7 @@ func TestAlibabaCloudProvider_ApplyChanges_HaveNoDefinedZoneDomain(t *testing.T) changes := plan.Changes{ Create: []*endpoint.Endpoint{ { - DNSName: "www.example.com", //no found this zone by API: DescribeDomains + DNSName: "www.example.com", // no found this zone by API: DescribeDomains RecordType: "A", RecordTTL: 300, Targets: endpoint.NewTargets("9.9.9.9"), diff --git a/provider/aws/aws.go b/provider/aws/aws.go index 9e896235c..873d860d8 100644 --- a/provider/aws/aws.go +++ b/provider/aws/aws.go @@ -53,19 +53,27 @@ const ( // 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" - providerSpecificMultiValueAnswer = "aws/multi-value-answer" - providerSpecificHealthCheckID = "aws/health-check-id" - sameZoneAlias = "same-zone" + 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 + 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 @@ -78,6 +86,7 @@ var canonicalHostedZones = map[string]string{ "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", @@ -115,6 +124,7 @@ var canonicalHostedZones = map[string]string{ "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", @@ -153,6 +163,7 @@ var canonicalHostedZones = map[string]string{ "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", @@ -186,6 +197,7 @@ var canonicalHostedZones = map[string]string{ "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", @@ -231,6 +243,12 @@ type profiledZone struct { 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 { @@ -454,10 +472,10 @@ func containsOctalSequence(domain string) bool { } // Records returns the list of records in a given hosted zone. -func (p *AWSProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { +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: %w", err) + return nil, provider.NewSoftErrorf("records retrieval failed: %v", err) } return p.records(ctx, zones) @@ -542,6 +560,8 @@ func (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZon 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 } @@ -560,6 +580,25 @@ func (p *AWSProvider) records(ctx context.Context, zones map[string]*profiledZon 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 @@ -602,6 +641,10 @@ func (p *AWSProvider) createUpdateChanges(newEndpoints, oldEndpoints []*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) @@ -691,56 +734,59 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes, log.Infof("Desired change: %s %s %s", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type) } - if !p.dryRun { - params := &route53.ChangeResourceRecordSetsInput{ - HostedZoneId: aws.String(z), - ChangeBatch: &route53types.ChangeBatch{ - Changes: b.Route53Changes(), - }, - } + if p.dryRun { + log.Debug("Dry run mode, skipping change submission") + continue + } - successfulChanges := 0 + params := &route53.ChangeResourceRecordSetsInput{ + HostedZoneId: aws.String(z), + ChangeBatch: &route53types.ChangeBatch{ + Changes: b.Route53Changes(), + }, + } - 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) + successfulChanges := 0 - changesByOwnership := groupChangesByNameAndOwnershipRelation(b) + 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) - 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 = successfulChanges + len(changes) + 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) } } - } else { - failedUpdate = true + 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 = successfulChanges + len(changes) + } } } else { - successfulChanges = len(b) + 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 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 i != len(batchCs)-1 { + time.Sleep(p.batchChangeInterval) } } @@ -832,12 +878,32 @@ func (p *AWSProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoi } else { ep.DeleteProviderSpecificProperty(providerSpecificEvaluateTargetHealth) } + + adjustGeoProximityLocationEndpoint(ep) } endpoints = append(endpoints, aliasCnameAaaaEndpoints...) return endpoints, nil } +// 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 @@ -926,6 +992,8 @@ func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.E if useGeolocation { change.ResourceRecordSet.GeoLocation = geolocation } + + withChangeForGeoProximityEndpoint(change, ep) } if prop, ok := ep.GetProviderSpecificProperty(providerSpecificHealthCheckID); ok { @@ -939,12 +1007,107 @@ func (p *AWSProvider) newChange(action route53types.ChangeAction, ep *endpoint.E 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) (foundChanges, notFoundChanges Route53Changes) { +func findChangesInQueue(changes Route53Changes, queue Route53Changes) (Route53Changes, Route53Changes) { if queue == nil { return Route53Changes{}, changes } + var foundChanges, notFoundChanges Route53Changes + for _, c := range changes { found := false for _, qc := range queue { @@ -959,7 +1122,7 @@ func findChangesInQueue(changes Route53Changes, queue Route53Changes) (foundChan } } - return + return foundChanges, notFoundChanges } // group the given changes by name and ownership relation to ensure these are always submitted in the same transaction to Route53; diff --git a/provider/aws/aws_test.go b/provider/aws/aws_test.go index 500d37161..a269c3ede 100644 --- a/provider/aws/aws_test.go +++ b/provider/aws/aws_test.go @@ -583,6 +583,42 @@ func TestAWSRecords(t *testing.T) { 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, @@ -636,6 +672,9 @@ func TestAWSRecords(t *testing.T) { 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"), @@ -670,6 +709,7 @@ func TestAWSAdjustEndpoints(t *testing.T) { 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) @@ -687,6 +727,7 @@ func TestAWSAdjustEndpoints(t *testing.T) { 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"), }) } @@ -845,6 +886,27 @@ func TestAWSApplyChanges(t *testing.T) { 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, @@ -915,6 +977,13 @@ func TestAWSApplyChanges(t *testing.T) { 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-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{ @@ -930,6 +999,9 @@ func TestAWSApplyChanges(t *testing.T) { 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"), @@ -951,6 +1023,9 @@ func TestAWSApplyChanges(t *testing.T) { 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"), @@ -969,6 +1044,7 @@ func TestAWSApplyChanges(t *testing.T) { 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"), } @@ -1118,6 +1194,40 @@ func TestAWSApplyChanges(t *testing.T) { TTL: aws.Int64(defaultTTL), ResourceRecords: []route53types.ResourceRecord{{Value: aws.String("10 mailhost1.foo.elb.amazonaws.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{ { @@ -1902,7 +2012,7 @@ func validateEndpoints(t *testing.T, provider *AWSProvider, endpoints []*endpoin normalized, err := provider.AdjustEndpoints(endpoints) assert.NoError(t, err) - assert.True(t, testutils.SameEndpoints(normalized, expected), "actual and normalized endpoints don't match. %+v:%+v", endpoints, normalized) + 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) { @@ -2370,3 +2480,373 @@ func TestConvertOctalToAscii(t *testing.T) { }) } } + +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, 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") +} diff --git a/provider/aws/config.go b/provider/aws/config.go index 5908150e7..ecc53c904 100644 --- a/provider/aws/config.go +++ b/provider/aws/config.go @@ -20,16 +20,16 @@ import ( "context" "fmt" "net/http" - "strings" 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/linki/instrumented_http" "github.com/sirupsen/logrus" + extdnshttp "sigs.k8s.io/external-dns/pkg/http" + "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) @@ -84,12 +84,7 @@ func newV2Config(awsConfig AWSSessionConfig) (awsv2.Config, error) { config.WithRetryer(func() awsv2.Retryer { return retry.AddWithMaxAttempts(retry.NewStandard(), awsConfig.APIRetries) }), - config.WithHTTPClient(instrumented_http.NewClient(&http.Client{}, &instrumented_http.Callbacks{ - PathProcessor: func(path string) string { - parts := strings.Split(path, "/") - return parts[len(parts)-1] - }, - })), + config.WithHTTPClient(extdnshttp.NewInstrumentedClient(&http.Client{})), config.WithSharedConfigProfile(awsConfig.Profile), } diff --git a/provider/awssd/aws_sd.go b/provider/awssd/aws_sd.go index f64e88728..9ee0822a7 100644 --- a/provider/awssd/aws_sd.go +++ b/provider/awssd/aws_sd.go @@ -78,7 +78,7 @@ type AWSSDProvider struct { // only consider namespaces ending in this suffix namespaceFilter *endpoint.DomainFilter // filter namespace by type (private or public) - namespaceTypeFilter sdtypes.NamespaceFilter + namespaceTypeFilter []sdtypes.NamespaceFilter // enables service without instances cleanup cleanEmptyService bool // filter services for removal @@ -102,21 +102,28 @@ func NewAWSSDProvider(domainFilter *endpoint.DomainFilter, namespaceType string, return p, nil } -// newSdNamespaceFilter initialized AWS SD Namespace Filter based on given string config -func newSdNamespaceFilter(namespaceTypeConfig string) sdtypes.NamespaceFilter { +// 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)}, + return []sdtypes.NamespaceFilter{ + { + Name: sdtypes.NamespaceFilterNameType, + Values: []string{string(sdtypes.NamespaceTypeDnsPublic)}, + }, } case sdNamespaceTypePrivate: - return sdtypes.NamespaceFilter{ - Name: sdtypes.NamespaceFilterNameType, - Values: []string{string(sdtypes.NamespaceTypeDnsPrivate)}, + return []sdtypes.NamespaceFilter{ + { + Name: sdtypes.NamespaceFilterNameType, + Values: []string{string(sdtypes.NamespaceTypeDnsPrivate)}, + }, } default: - return sdtypes.NamespaceFilter{} + return []sdtypes.NamespaceFilter{} } } @@ -130,12 +137,14 @@ func awsTags(tags map[string]string) []sdtypes.Tag { } // Records returns list of all endpoints. -func (p *AWSSDProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, err error) { +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 { @@ -244,12 +253,14 @@ func (p *AWSSDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) return nil } -func (p *AWSSDProvider) updatesToCreates(changes *plan.Changes) (creates []*endpoint.Endpoint, deletes []*endpoint.Endpoint) { +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] @@ -350,7 +361,7 @@ func (p *AWSSDProvider) ListNamespaces(ctx context.Context) ([]*sdtypes.Namespac namespaces := make([]*sdtypes.NamespaceSummary, 0) paginator := sd.NewListNamespacesPaginator(p.client, &sd.ListNamespacesInput{ - Filters: []sdtypes.NamespaceFilter{p.namespaceTypeFilter}, + Filters: p.namespaceTypeFilter, }) for paginator.HasMorePages() { resp, err := paginator.NextPage(ctx) @@ -618,12 +629,10 @@ func matchingNamespaces(hostname string, namespaces []*sdtypes.NamespaceSummary) return matchingNamespaces } -// parse hostname to namespace (domain) and service -func (p *AWSSDProvider) parseHostname(hostname string) (namespace string, service string) { +// parseHostname parse hostname to namespace (domain) and service +func (p *AWSSDProvider) parseHostname(hostname string) (string, string) { parts := strings.Split(hostname, ".") - service = parts[0] - namespace = strings.Join(parts[1:], ".") - return + return strings.Join(parts[1:], "."), parts[0] } // determine service routing policy based on endpoint type diff --git a/provider/awssd/aws_sd_test.go b/provider/awssd/aws_sd_test.go index 3bf592200..3cb4e7584 100644 --- a/provider/awssd/aws_sd_test.go +++ b/provider/awssd/aws_sd_test.go @@ -254,14 +254,14 @@ func TestAWSSDProvider_ApplyChanges_Update(t *testing.T) { ctx := context.Background() // apply creates - provider.ApplyChanges(ctx, &plan.Changes{ + _ = provider.ApplyChanges(ctx, &plan.Changes{ Create: oldEndpoints, }) ctx = context.Background() // apply update - provider.ApplyChanges(ctx, &plan.Changes{ + _ = provider.ApplyChanges(ctx, &plan.Changes{ UpdateOld: oldEndpoints, UpdateNew: newEndpoints, }) @@ -306,6 +306,7 @@ func TestAWSSDProvider_ListNamespaces(t *testing.T) { }{ {"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{}}, } { @@ -913,7 +914,7 @@ func TestAWSSDProvider_RegisterInstance(t *testing.T) { } // AWS NLB instance (ALIAS) - provider.RegisterInstance(context.Background(), services["private"]["alias-srv"], &endpoint.Endpoint{ + _ = provider.RegisterInstance(context.Background(), services["private"]["alias-srv"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeCNAME, DNSName: "service1.private.com.", RecordTTL: 300, @@ -927,7 +928,7 @@ func TestAWSSDProvider_RegisterInstance(t *testing.T) { } // CNAME instance - provider.RegisterInstance(context.Background(), services["private"]["cname-srv"], &endpoint.Endpoint{ + _ = provider.RegisterInstance(context.Background(), services["private"]["cname-srv"], &endpoint.Endpoint{ RecordType: endpoint.RecordTypeCNAME, DNSName: "service2.private.com.", RecordTTL: 300, @@ -1001,7 +1002,7 @@ func TestAWSSDProvider_DeregisterInstance(t *testing.T) { provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "") - provider.DeregisterInstance(context.Background(), services["private"]["srv1"], endpoint.NewEndpoint("srv1.private.com.", endpoint.RecordTypeA, "1.2.3.4")) + _ = provider.DeregisterInstance(context.Background(), services["private"]["srv1"], endpoint.NewEndpoint("srv1.private.com.", endpoint.RecordTypeA, "1.2.3.4")) assert.Empty(t, instances["srv1"]) } diff --git a/provider/azure/azure.go b/provider/azure/azure.go index 438035e95..d53efa32f 100644 --- a/provider/azure/azure.go +++ b/provider/azure/azure.go @@ -106,12 +106,14 @@ func NewAzureProvider(configFile string, domainFilter *endpoint.DomainFilter, zo // Records gets the current records. // // Returns the current records or an error if the operation failed. -func (p *AzureProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { +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() { diff --git a/provider/azure/azure_private_dns.go b/provider/azure/azure_private_dns.go index 108600556..2e0fefd33 100644 --- a/provider/azure/azure_private_dns.go +++ b/provider/azure/azure_private_dns.go @@ -101,7 +101,7 @@ func NewAzurePrivateDNSProvider(configFile string, domainFilter *endpoint.Domain // Records gets the current records. // // Returns the current records or an error if the operation failed. -func (p *AzurePrivateDNSProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { +func (p *AzurePrivateDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.zones(ctx) if err != nil { return nil, err @@ -109,12 +109,13 @@ func (p *AzurePrivateDNSProvider) Records(ctx context.Context) (endpoints []*end 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.NewSoftError(fmt.Errorf("failed to fetch dns records: %w", err)) + return nil, provider.NewSoftErrorf("failed to fetch dns records: %v", err) } for _, recordSet := range nextResult.Value { diff --git a/provider/cached_provider.go b/provider/cached_provider.go index 8f86d704c..ff9d2d49b 100644 --- a/provider/cached_provider.go +++ b/provider/cached_provider.go @@ -31,7 +31,6 @@ import ( var ( cachedRecordsCallsTotal = metrics.NewCounterVecWithOpts( prometheus.CounterOpts{ - Namespace: "external_dns", Subsystem: "provider", Name: "cache_records_calls", Help: "Number of calls to the provider cache Records list.", @@ -42,7 +41,6 @@ var ( ) cachedApplyChangesCallsTotal = metrics.NewCounterWithOpts( prometheus.CounterOpts{ - Namespace: "external_dns", Subsystem: "provider", Name: "cache_apply_changes_calls", Help: "Number of calls to the provider cache ApplyChanges.", diff --git a/provider/civo/civo_test.go b/provider/civo/civo_test.go index 27020a274..80910887d 100644 --- a/provider/civo/civo_test.go +++ b/provider/civo/civo_test.go @@ -1169,7 +1169,7 @@ func TestCivoChangesEmpty(t *testing.T) { // 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 interface{}, msgAndArgs ...interface{}) (ok bool) { +func elementsMatch(t *testing.T, listA, listB interface{}, msgAndArgs ...interface{}) bool { if listA == nil && listB == nil { return true } else if listA == nil { diff --git a/provider/cloudflare/cloudflare.go b/provider/cloudflare/cloudflare.go index 574212da4..0b432b3f3 100644 --- a/provider/cloudflare/cloudflare.go +++ b/provider/cloudflare/cloudflare.go @@ -29,6 +29,10 @@ import ( "strings" "github.com/cloudflare/cloudflare-go" + cloudflarev4 "github.com/cloudflare/cloudflare-go/v4" + "github.com/cloudflare/cloudflare-go/v4/addressing" + "github.com/cloudflare/cloudflare-go/v4/option" + "github.com/cloudflare/cloudflare-go/v4/zones" log "github.com/sirupsen/logrus" "golang.org/x/net/publicsuffix" @@ -103,27 +107,44 @@ var recordTypeCustomHostnameSupported = map[string]bool{ // 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) - ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) - ZoneDetails(ctx context.Context, zoneID string) (cloudflare.Zone, error) + ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] + GetZone(ctx context.Context, zoneID string) (*zones.Zone, error) ListDNSRecords(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDNSRecordsParams) ([]cloudflare.DNSRecord, *cloudflare.ResultInfo, error) CreateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) DeleteDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, recordID string) error UpdateDNSRecord(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDNSRecordParams) error - ListDataLocalizationRegionalHostnames(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDataLocalizationRegionalHostnamesParams) ([]cloudflare.RegionalHostname, error) - CreateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDataLocalizationRegionalHostnameParams) error - UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error - DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, hostname string) 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, page int, filter cloudflare.CustomHostname) ([]cloudflare.CustomHostname, cloudflare.ResultInfo, error) DeleteCustomHostname(ctx context.Context, zoneID string, customHostnameID string) error CreateCustomHostname(ctx context.Context, zoneID string, ch cloudflare.CustomHostname) (*cloudflare.CustomHostnameResponse, error) } type zoneService struct { - service *cloudflare.API + service *cloudflare.API + serviceV4 *cloudflarev4.Client } func (z zoneService) ZoneIDByName(zoneName string) (string, error) { - return z.service.ZoneIDByName(zoneName) + // Use v4 API to find zone by name + params := zones.ZoneListParams{ + Name: cloudflarev4.F(zoneName), + } + + iter := z.serviceV4.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, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDNSRecordParams) (cloudflare.DNSRecord, error) { @@ -143,12 +164,12 @@ func (z zoneService) DeleteDNSRecord(ctx context.Context, rc *cloudflare.Resourc return z.service.DeleteDNSRecord(ctx, rc, recordID) } -func (z zoneService) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { - return z.service.ListZonesContext(ctx, opts...) +func (z zoneService) ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] { + return z.serviceV4.Zones.ListAutoPaging(ctx, params) } -func (z zoneService) ZoneDetails(ctx context.Context, zoneID string) (cloudflare.Zone, error) { - return z.service.ZoneDetails(ctx, zoneID) +func (z zoneService) GetZone(ctx context.Context, zoneID string) (*zones.Zone, error) { + return z.serviceV4.Zones.Get(ctx, zones.ZoneGetParams{ZoneID: cloudflarev4.F(zoneID)}) } func (z zoneService) CustomHostnames(ctx context.Context, zoneID string, page int, filter cloudflare.CustomHostname) ([]cloudflare.CustomHostname, cloudflare.ResultInfo, error) { @@ -163,21 +184,31 @@ func (z zoneService) CreateCustomHostname(ctx context.Context, zoneID string, ch return z.service.CreateCustomHostname(ctx, zoneID, ch) } +// listZonesV4Params returns the appropriate Zone List Params for v4 API +func listZonesV4Params() zones.ZoneListParams { + return zones.ZoneListParams{} +} + type DNSRecordsConfig struct { PerPage int Comment string } func (c *DNSRecordsConfig) trimAndValidateComment(dnsName, comment string, paidZone func(string) bool) string { - if len(comment) > freeZoneMaxCommentLength { - if !paidZone(dnsName) { - log.Warnf("DNS record comment is invalid. Trimming comment of %s. To avoid endless syncs, please set it to less than %d chars.", dnsName, freeZoneMaxCommentLength) - return comment[:freeZoneMaxCommentLength] - } else if len(comment) > paidZoneMaxCommentLength { - log.Warnf("DNS record comment is invalid. Trimming comment of %s. To avoid endless syncs, please set it to less than %d chars.", dnsName, paidZoneMaxCommentLength) - return comment[:paidZoneMaxCommentLength] - } + 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 } @@ -193,13 +224,13 @@ func (p *CloudFlareProvider) ZoneHasPaidPlan(hostname string) bool { return false } - zoneDetails, err := p.Client.ZoneDetails(context.Background(), zoneID) + 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 + 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. @@ -220,7 +251,7 @@ type CloudFlareProvider struct { type cloudFlareChange struct { Action changeAction ResourceRecord cloudflare.DNSRecord - RegionalHostname cloudflare.RegionalHostname + RegionalHostname regionalHostname CustomHostnames map[string]cloudflare.CustomHostname CustomHostnamesPrev []string } @@ -239,6 +270,7 @@ func updateDNSRecordParam(cfc cloudFlareChange) cloudflare.UpdateDNSRecordParams Type: cfc.ResourceRecord.Type, Content: cfc.ResourceRecord.Content, Priority: cfc.ResourceRecord.Priority, + Comment: cloudflare.StringPtr(cfc.ResourceRecord.Comment), } return params @@ -253,6 +285,7 @@ func getCreateDNSRecordParam(cfc cloudFlareChange) cloudflare.CreateDNSRecordPar Type: cfc.ResourceRecord.Type, Content: cfc.ResourceRecord.Content, Priority: cfc.ResourceRecord.Priority, + Comment: cfc.ResourceRecord.Comment, } return params @@ -287,8 +320,9 @@ func NewCloudFlareProvider( ) (*CloudFlareProvider, error) { // initialize via chosen auth method and returns new API object var ( - config *cloudflare.API - err error + config *cloudflare.API + configV4 *cloudflarev4.Client + err error ) if os.Getenv("CF_API_TOKEN") != "" { token := os.Getenv("CF_API_TOKEN") @@ -300,8 +334,15 @@ func NewCloudFlareProvider( token = strings.TrimSpace(string(tokenBytes)) } config, err = cloudflare.NewWithAPIToken(token) + configV4 = cloudflarev4.NewClient( + option.WithAPIToken(token), + ) } else { config, err = cloudflare.New(os.Getenv("CF_API_KEY"), os.Getenv("CF_API_EMAIL")) + configV4 = cloudflarev4.NewClient( + option.WithAPIKey(os.Getenv("CF_API_KEY")), + option.WithAPIEmail(os.Getenv("CF_API_EMAIL")), + ) } if err != nil { return nil, fmt.Errorf("failed to initialize cloudflare provider: %w", err) @@ -312,7 +353,7 @@ func NewCloudFlareProvider( } return &CloudFlareProvider{ - Client: zoneService{config}, + Client: zoneService{config, configV4}, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, proxiedByDefault: proxiedByDefault, @@ -324,8 +365,8 @@ func NewCloudFlareProvider( } // Zones returns the list of hosted zones. -func (p *CloudFlareProvider) Zones(ctx context.Context) ([]cloudflare.Zone, error) { - var result []cloudflare.Zone +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) @@ -333,34 +374,38 @@ func (p *CloudFlareProvider) Zones(ctx context.Context) ([]cloudflare.Zone, erro 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.ZoneDetails(ctx, zoneID) + detailResponse, err := p.Client.GetZone(ctx, zoneID) if err != nil { log.Errorf("zone %q lookup failed, %v", zoneID, err) - return result, err + return result, convertCloudflareError(err) } log.WithFields(log.Fields{ "zoneName": detailResponse.Name, "zoneID": detailResponse.ID, }).Debugln("adding zone for consideration") - result = append(result, detailResponse) + result = append(result, *detailResponse) } return result, nil } log.Debugln("no zoneIDFilter configured, looking at all zones") - zonesResponse, err := p.Client.ListZonesContext(ctx) - if err != nil { - return nil, convertCloudflareError(err) - } - - for _, zone := range zonesResponse.Result { + 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 } @@ -579,7 +624,7 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud "record": change.ResourceRecord.Name, "type": change.ResourceRecord.Type, "ttl": change.ResourceRecord.TTL, - "action": change.Action, + "action": change.Action.String(), "zone": zoneID, } @@ -646,12 +691,12 @@ func (p *CloudFlareProvider) submitChanges(ctx context.Context, changes []*cloud return fmt.Errorf("failed to build desired regional hostnames: %w", err) } if len(desiredRegionalHostnames) > 0 { - regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, resourceContainer) + 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, regionalHostnamesChanges, resourceContainer) { + if !p.submitRegionalHostnameChanges(ctx, zoneID, regionalHostnamesChanges) { failedChange = true } } @@ -703,7 +748,7 @@ func (p *CloudFlareProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([] } // changesByZone separates a multi-zone change into a single change per zone. -func (p *CloudFlareProvider) changesByZone(zones []cloudflare.Zone, changeSet []*cloudFlareChange) map[string][]*cloudFlareChange { +func (p *CloudFlareProvider) changesByZone(zones []zones.Zone, changeSet []*cloudFlareChange) map[string][]*cloudFlareChange { changes := make(map[string][]*cloudFlareChange) zoneNameIDMapper := provider.ZoneIDName{} diff --git a/provider/cloudflare/cloudflare_regional.go b/provider/cloudflare/cloudflare_regional.go index 6e93e2c72..9502bcc92 100644 --- a/provider/cloudflare/cloudflare_regional.go +++ b/provider/cloudflare/cloudflare_regional.go @@ -22,7 +22,9 @@ import ( "maps" "slices" - cloudflare "github.com/cloudflare/cloudflare-go" + cloudflarev4 "github.com/cloudflare/cloudflare-go/v4" + "github.com/cloudflare/cloudflare-go/v4/addressing" + log "github.com/sirupsen/logrus" "sigs.k8s.io/external-dns/endpoint" @@ -40,54 +42,75 @@ var recordTypeRegionalHostnameSupported = map[string]bool{ "CNAME": true, } -// RegionalHostnamesMap is a map of regional hostnames keyed by hostname. -type RegionalHostnamesMap map[string]cloudflare.RegionalHostname +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 - cloudflare.RegionalHostname + regionalHostname } -func (z zoneService) ListDataLocalizationRegionalHostnames(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDataLocalizationRegionalHostnamesParams) ([]cloudflare.RegionalHostname, error) { - return z.service.ListDataLocalizationRegionalHostnames(ctx, rc, rp) +func (z zoneService) ListDataLocalizationRegionalHostnames(ctx context.Context, params addressing.RegionalHostnameListParams) autoPager[addressing.RegionalHostnameListResponse] { + return z.serviceV4.Addressing.RegionalHostnames.ListAutoPaging(ctx, params) } -func (z zoneService) CreateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDataLocalizationRegionalHostnameParams) error { - _, err := z.service.CreateDataLocalizationRegionalHostname(ctx, rc, rp) +func (z zoneService) CreateDataLocalizationRegionalHostname(ctx context.Context, params addressing.RegionalHostnameNewParams) error { + _, err := z.serviceV4.Addressing.RegionalHostnames.New(ctx, params) return err } -func (z zoneService) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error { - _, err := z.service.UpdateDataLocalizationRegionalHostname(ctx, rc, rp) +func (z zoneService) UpdateDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameEditParams) error { + _, err := z.serviceV4.Addressing.RegionalHostnames.Edit(ctx, hostname, params) return err } -func (z zoneService) DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, hostname string) error { - return z.service.DeleteDataLocalizationRegionalHostname(ctx, rc, hostname) +func (z zoneService) DeleteDataLocalizationRegionalHostname(ctx context.Context, hostname string, params addressing.RegionalHostnameDeleteParams) error { + _, err := z.serviceV4.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: cloudflarev4.F(zoneID), + } } // createDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in -func createDataLocalizationRegionalHostnameParams(rhc regionalHostnameChange) cloudflare.CreateDataLocalizationRegionalHostnameParams { - return cloudflare.CreateDataLocalizationRegionalHostnameParams{ - Hostname: rhc.Hostname, - RegionKey: rhc.RegionKey, +func createDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameNewParams { + return addressing.RegionalHostnameNewParams{ + ZoneID: cloudflarev4.F(zoneID), + Hostname: cloudflarev4.F(rhc.hostname), + RegionKey: cloudflarev4.F(rhc.regionKey), } } // updateDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in -func updateDataLocalizationRegionalHostnameParams(rhc regionalHostnameChange) cloudflare.UpdateDataLocalizationRegionalHostnameParams { - return cloudflare.UpdateDataLocalizationRegionalHostnameParams{ - Hostname: rhc.Hostname, - RegionKey: rhc.RegionKey, +func updateDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameEditParams { + return addressing.RegionalHostnameEditParams{ + ZoneID: cloudflarev4.F(zoneID), + RegionKey: cloudflarev4.F(rhc.regionKey), + } +} + +// deleteDataLocalizationRegionalHostnameParams is a function that returns the appropriate RegionalHostname Param based on the cloudFlareChange passed in +func deleteDataLocalizationRegionalHostnameParams(zoneID string, rhc regionalHostnameChange) addressing.RegionalHostnameDeleteParams { + return addressing.RegionalHostnameDeleteParams{ + ZoneID: cloudflarev4.F(zoneID), } } // submitRegionalHostnameChanges applies a set of regional hostname changes, returns false if at least one fails -func (p *CloudFlareProvider) submitRegionalHostnameChanges(ctx context.Context, rhChanges []regionalHostnameChange, resourceContainer *cloudflare.ResourceContainer) bool { +func (p *CloudFlareProvider) submitRegionalHostnameChanges(ctx context.Context, zoneID string, rhChanges []regionalHostnameChange) bool { failedChange := false for _, rhChange := range rhChanges { - if !p.submitRegionalHostnameChange(ctx, rhChange, resourceContainer) { + if !p.submitRegionalHostnameChange(ctx, zoneID, rhChange) { failedChange = true } } @@ -96,12 +119,12 @@ func (p *CloudFlareProvider) submitRegionalHostnameChanges(ctx context.Context, } // submitRegionalHostnameChange applies a single regional hostname change, returns false if it fails -func (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, rhChange regionalHostnameChange, resourceContainer *cloudflare.ResourceContainer) bool { +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, - "zone": resourceContainer.Identifier, + "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) @@ -110,21 +133,22 @@ func (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, r switch rhChange.action { case cloudFlareCreate: changeLog.Debug("Creating regional hostname") - regionalHostnameParam := createDataLocalizationRegionalHostnameParams(rhChange) - if err := p.Client.CreateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam); err != nil { + 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") - regionalHostnameParam := updateDataLocalizationRegionalHostnameParams(rhChange) - if err := p.Client.UpdateDataLocalizationRegionalHostname(ctx, resourceContainer, regionalHostnameParam); err != nil { + 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") - if err := p.Client.DeleteDataLocalizationRegionalHostname(ctx, resourceContainer, rhChange.Hostname); err != nil { + params := deleteDataLocalizationRegionalHostnameParams(zoneID, rhChange) + if err := p.Client.DeleteDataLocalizationRegionalHostname(ctx, rhChange.hostname, params); err != nil { changeLog.Errorf("failed to delete regional hostname: %v", err) return false } @@ -132,34 +156,41 @@ func (p *CloudFlareProvider) submitRegionalHostnameChange(ctx context.Context, r return true } -func (p *CloudFlareProvider) listDataLocalisationRegionalHostnames(ctx context.Context, resourceContainer *cloudflare.ResourceContainer) (RegionalHostnamesMap, error) { - rhs, err := p.Client.ListDataLocalizationRegionalHostnames(ctx, resourceContainer, cloudflare.ListDataLocalizationRegionalHostnamesParams{}) - if err != nil { - return nil, convertCloudflareError(err) +// 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, + } } - rhsMap := make(RegionalHostnamesMap) - for _, r := range rhs { - rhsMap[r.Hostname] = r + if iter.Err() != nil { + return nil, convertCloudflareError(iter.Err()) } return rhsMap, nil } -// regionalHostname returns a RegionalHostname for the given endpoint. +// 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. +// 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) cloudflare.RegionalHostname { +func (p *CloudFlareProvider) regionalHostname(ep *endpoint.Endpoint) regionalHostname { if !p.RegionalServicesConfig.Enabled || !recordTypeRegionalHostnameSupported[ep.RecordType] { - return cloudflare.RegionalHostname{} + return regionalHostname{} } regionKey := p.RegionalServicesConfig.RegionKey if epRegionKey, exists := ep.GetProviderSpecificProperty(annotations.CloudflareRegionKey); exists { regionKey = epRegionKey } - return cloudflare.RegionalHostname{ - Hostname: ep.DNSName, - RegionKey: regionKey, + return regionalHostname{ + hostname: ep.DNSName, + regionKey: regionKey, } } @@ -185,14 +216,14 @@ func (p *CloudFlareProvider) addEnpointsProviderSpecificRegionKeyProperty(ctx co return nil } - regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, cloudflare.ZoneIdentifier(zoneID)) + regionalHostnames, err := p.listDataLocalisationRegionalHostnames(ctx, zoneID) if err != nil { return err } for _, ep := range supportedEndpoints { if rh, found := regionalHostnames[ep.DNSName]; found { - ep.SetProviderSpecificProperty(annotations.CloudflareRegionKey, rh.RegionKey) + ep.SetProviderSpecificProperty(annotations.CloudflareRegionKey, rh.regionKey) } } return nil @@ -203,67 +234,67 @@ func (p *CloudFlareProvider) addEnpointsProviderSpecificRegionKeyProperty(ctx co // 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) ([]cloudflare.RegionalHostname, error) { - rhs := make(map[string]cloudflare.RegionalHostname) +func desiredRegionalHostnames(changes []*cloudFlareChange) ([]regionalHostname, error) { + rhs := make(map[string]regionalHostname) for _, change := range changes { - if change.RegionalHostname.Hostname == "" { + if change.RegionalHostname.hostname == "" { continue } - rh, found := rhs[change.RegionalHostname.Hostname] + rh, found := rhs[change.RegionalHostname.hostname] if !found { if change.Action == cloudFlareDelete { - rhs[change.RegionalHostname.Hostname] = cloudflare.RegionalHostname{ - Hostname: change.RegionalHostname.Hostname, - RegionKey: "", // Indicate that this regional hostname should not exists + 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 + 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 rh.regionKey == "" { // If the existing regional hostname has no region key, we can overwrite it - rhs[change.RegionalHostname.Hostname] = change.RegionalHostname + 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) + 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 []cloudflare.RegionalHostname, regionalHostnames RegionalHostnamesMap) []regionalHostnameChange { +func regionalHostnamesChanges(desired []regionalHostname, regionalHostnames regionalHostnamesMap) []regionalHostnameChange { changes := make([]regionalHostnameChange, 0) for _, rh := range desired { - current, found := regionalHostnames[rh.Hostname] - if rh.RegionKey == "" { + 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, + regionalHostname: rh, }) continue } if !found { changes = append(changes, regionalHostnameChange{ action: cloudFlareCreate, - RegionalHostname: rh, + regionalHostname: rh, }) continue } - if rh.RegionKey != current.RegionKey { + if rh.regionKey != current.regionKey { changes = append(changes, regionalHostnameChange{ action: cloudFlareUpdate, - RegionalHostname: rh, + regionalHostname: rh, }) } } diff --git a/provider/cloudflare/cloudflare_regional_test.go b/provider/cloudflare/cloudflare_regional_test.go index fbbca0d90..aee88dfe0 100644 --- a/provider/cloudflare/cloudflare_regional_test.go +++ b/provider/cloudflare/cloudflare_regional_test.go @@ -24,6 +24,7 @@ import ( "testing" cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/cloudflare-go/v4/addressing" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -33,57 +34,67 @@ import ( "sigs.k8s.io/external-dns/plan" ) -func (m *mockCloudFlareClient) ListDataLocalizationRegionalHostnames(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.ListDataLocalizationRegionalHostnamesParams) ([]cloudflare.RegionalHostname, error) { - if strings.Contains(rc.Identifier, "rherror") { - return nil, fmt.Errorf("failed to list regional hostnames") +func (m *mockCloudFlareClient) ListDataLocalizationRegionalHostnames(ctx 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, } - return m.regionalHostnames[rc.Identifier], nil } -func (m *mockCloudFlareClient) CreateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.CreateDataLocalizationRegionalHostnameParams) error { - if strings.Contains(rp.Hostname, "rherror") { +func (m *mockCloudFlareClient) CreateDataLocalizationRegionalHostname(ctx 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: rc.Identifier, + ZoneId: params.ZoneID.Value, RecordId: "", - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: rp.Hostname, - RegionKey: rp.RegionKey, + RegionalHostname: regionalHostname{ + hostname: params.Hostname.Value, + regionKey: params.RegionKey.Value, }, }) return nil } -func (m *mockCloudFlareClient) UpdateDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, rp cloudflare.UpdateDataLocalizationRegionalHostnameParams) error { - if strings.Contains(rp.Hostname, "rherror") { +func (m *mockCloudFlareClient) UpdateDataLocalizationRegionalHostname(ctx 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: rc.Identifier, + ZoneId: params.ZoneID.Value, RecordId: "", - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: rp.Hostname, - RegionKey: rp.RegionKey, + RegionalHostname: regionalHostname{ + hostname: hostname, + regionKey: params.RegionKey.Value, }, }) return nil } -func (m *mockCloudFlareClient) DeleteDataLocalizationRegionalHostname(ctx context.Context, rc *cloudflare.ResourceContainer, hostname string) error { +func (m *mockCloudFlareClient) DeleteDataLocalizationRegionalHostname(ctx 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: rc.Identifier, + ZoneId: params.ZoneID.Value, RecordId: "", - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: hostname, + RegionalHostname: regionalHostname{ + hostname: hostname, }, }) return nil @@ -93,14 +104,14 @@ func TestCloudflareRegionalHostnameActions(t *testing.T) { tests := []struct { name string records map[string]cloudflare.DNSRecord - regionalHostnames []cloudflare.RegionalHostname + regionalHostnames []regionalHostname endpoints []*endpoint.Endpoint want []MockAction }{ { name: "create", records: map[string]cloudflare.DNSRecord{}, - regionalHostnames: []cloudflare.RegionalHostname{}, + regionalHostnames: []regionalHostname{}, endpoints: []*endpoint.Endpoint{ { RecordType: "A", @@ -131,9 +142,9 @@ func TestCloudflareRegionalHostnameActions(t *testing.T) { { Name: "CreateDataLocalizationRegionalHostname", ZoneId: "001", - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "create.bar.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "create.bar.com", + regionKey: "eu", }, }, }, @@ -150,10 +161,10 @@ func TestCloudflareRegionalHostnameActions(t *testing.T) { Proxied: proxyDisabled, }, }, - regionalHostnames: []cloudflare.RegionalHostname{ + regionalHostnames: []regionalHostname{ { - Hostname: "update.bar.com", - RegionKey: "us", + hostname: "update.bar.com", + regionKey: "us", }, }, endpoints: []*endpoint.Endpoint{ @@ -186,9 +197,9 @@ func TestCloudflareRegionalHostnameActions(t *testing.T) { { Name: "UpdateDataLocalizationRegionalHostname", ZoneId: "001", - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "update.bar.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "update.bar.com", + regionKey: "eu", }, }, }, @@ -205,10 +216,10 @@ func TestCloudflareRegionalHostnameActions(t *testing.T) { Proxied: proxyDisabled, }, }, - regionalHostnames: []cloudflare.RegionalHostname{ + regionalHostnames: []regionalHostname{ { - Hostname: "delete.bar.com", - RegionKey: "us", + hostname: "delete.bar.com", + regionKey: "us", }, }, endpoints: []*endpoint.Endpoint{}, @@ -222,8 +233,8 @@ func TestCloudflareRegionalHostnameActions(t *testing.T) { { Name: "DeleteDataLocalizationRegionalHostname", ZoneId: "001", - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "delete.bar.com", + RegionalHostname: regionalHostname{ + hostname: "delete.bar.com", }, }, }, @@ -240,10 +251,10 @@ func TestCloudflareRegionalHostnameActions(t *testing.T) { Proxied: proxyDisabled, }, }, - regionalHostnames: []cloudflare.RegionalHostname{ + regionalHostnames: []regionalHostname{ { - Hostname: "nochange.bar.com", - RegionKey: "eu", + hostname: "nochange.bar.com", + regionKey: "eu", }, }, endpoints: []*endpoint.Endpoint{ @@ -273,7 +284,7 @@ func TestCloudflareRegionalHostnameActions(t *testing.T) { Records: map[string]map[string]cloudflare.DNSRecord{ "001": tt.records, }, - regionalHostnames: map[string][]cloudflare.RegionalHostname{ + regionalHostnames: map[string][]regionalHostname{ "001": tt.regionalHostnames, }, }, @@ -323,9 +334,9 @@ func TestCloudflareRegionalHostnameDefaults(t *testing.T) { { Name: "CreateDataLocalizationRegionalHostname", ZoneId: "001", - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "bar.com", - RegionKey: "us", + RegionalHostname: regionalHostname{ + hostname: "bar.com", + regionKey: "us", }, }, }, @@ -341,7 +352,7 @@ func Test_regionalHostname(t *testing.T) { tests := []struct { name string args args - want cloudflare.RegionalHostname + want regionalHostname }{ { name: "no region key", @@ -355,9 +366,9 @@ func Test_regionalHostname(t *testing.T) { RegionKey: "", }, }, - want: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "", + want: regionalHostname{ + hostname: "example.com", + regionKey: "", }, }, { @@ -372,9 +383,9 @@ func Test_regionalHostname(t *testing.T) { RegionKey: "us", }, }, - want: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "us", + want: regionalHostname{ + hostname: "example.com", + regionKey: "us", }, }, { @@ -395,9 +406,9 @@ func Test_regionalHostname(t *testing.T) { RegionKey: "us", }, }, - want: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + want: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, { @@ -418,9 +429,9 @@ func Test_regionalHostname(t *testing.T) { RegionKey: "us", }, }, - want: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "", + want: regionalHostname{ + hostname: "example.com", + regionKey: "", }, }, { @@ -441,7 +452,7 @@ func Test_regionalHostname(t *testing.T) { RegionKey: "us", }, }, - want: cloudflare.RegionalHostname{}, + want: regionalHostname{}, }, { name: "disabled", @@ -460,9 +471,9 @@ func Test_regionalHostname(t *testing.T) { Enabled: false, }, }, - want: cloudflare.RegionalHostname{ - Hostname: "", - RegionKey: "", + want: regionalHostname{ + hostname: "", + regionKey: "", }, }, } @@ -479,7 +490,7 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { tests := []struct { name string changes []*cloudFlareChange - want []cloudflare.RegionalHostname + want []regionalHostname wantErr bool }{ { @@ -501,23 +512,23 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { changes: []*cloudFlareChange{ { Action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, { Action: cloudFlareUpdate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, }, - want: []cloudflare.RegionalHostname{ + want: []regionalHostname{ { - Hostname: "example.com", - RegionKey: "eu", + hostname: "example.com", + regionKey: "eu", }, }, wantErr: false, @@ -527,16 +538,16 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { changes: []*cloudFlareChange{ { Action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, { Action: cloudFlareUpdate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "us", // Different region key + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "us", // Different region key }, }, }, @@ -548,38 +559,38 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { changes: []*cloudFlareChange{ { Action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example1.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example1.com", + regionKey: "eu", }, }, { Action: cloudFlareUpdate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example2.com", - RegionKey: "us", + RegionalHostname: regionalHostname{ + hostname: "example2.com", + regionKey: "us", }, }, { Action: cloudFlareDelete, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example3.com", - RegionKey: "us", + RegionalHostname: regionalHostname{ + hostname: "example3.com", + regionKey: "us", }, }, }, - want: []cloudflare.RegionalHostname{ + want: []regionalHostname{ { - Hostname: "example1.com", - RegionKey: "eu", + hostname: "example1.com", + regionKey: "eu", }, { - Hostname: "example2.com", - RegionKey: "us", + hostname: "example2.com", + regionKey: "us", }, { - Hostname: "example3.com", - RegionKey: "", + hostname: "example3.com", + regionKey: "", }, }, wantErr: false, @@ -589,16 +600,16 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { changes: []*cloudFlareChange{ { Action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "", // Empty region key + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "", // Empty region key }, }, }, - want: []cloudflare.RegionalHostname{ + want: []regionalHostname{ { - Hostname: "example.com", - RegionKey: "", + hostname: "example.com", + regionKey: "", }, }, wantErr: false, @@ -608,23 +619,23 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { changes: []*cloudFlareChange{ { Action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "", // Empty region key + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "", // Empty region key }, }, { Action: cloudFlareUpdate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, }, - want: []cloudflare.RegionalHostname{ + want: []regionalHostname{ { - Hostname: "example.com", - RegionKey: "eu", + hostname: "example.com", + regionKey: "eu", }, }, wantErr: false, @@ -634,23 +645,23 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { changes: []*cloudFlareChange{ { Action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, { Action: cloudFlareUpdate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", // Empty region key + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", // Empty region key }, }, }, - want: []cloudflare.RegionalHostname{ + want: []regionalHostname{ { - Hostname: "example.com", - RegionKey: "eu", + hostname: "example.com", + regionKey: "eu", }, }, wantErr: false, @@ -660,23 +671,23 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { changes: []*cloudFlareChange{ { Action: cloudFlareDelete, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, { Action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, }, - want: []cloudflare.RegionalHostname{ + want: []regionalHostname{ { - Hostname: "example.com", - RegionKey: "eu", + hostname: "example.com", + regionKey: "eu", }, }, wantErr: false, @@ -686,23 +697,23 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { changes: []*cloudFlareChange{ { Action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, { Action: cloudFlareDelete, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "example.com", - RegionKey: "eu", + RegionalHostname: regionalHostname{ + hostname: "example.com", + regionKey: "eu", }, }, }, - want: []cloudflare.RegionalHostname{ + want: []regionalHostname{ { - Hostname: "example.com", - RegionKey: "eu", + hostname: "example.com", + regionKey: "eu", }, }, wantErr: false, @@ -718,10 +729,10 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { assert.NoError(t, err) } sort.Slice(got, func(i, j int) bool { - return got[i].Hostname < got[j].Hostname + 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 + return tt.want[i].hostname < tt.want[j].hostname }) assert.Equal(t, tt.want, got) }) @@ -731,74 +742,74 @@ func Test_desiredDataLocalizationRegionalHostnames(t *testing.T) { func Test_dataLocalizationRegionalHostnamesChanges(t *testing.T) { tests := []struct { name string - desired []cloudflare.RegionalHostname - regionalHostnames RegionalHostnamesMap + desired []regionalHostname + regionalHostnames regionalHostnamesMap want []regionalHostnameChange }{ { name: "empty desired and current lists", - desired: []cloudflare.RegionalHostname{}, - regionalHostnames: RegionalHostnamesMap{}, + desired: []regionalHostname{}, + regionalHostnames: regionalHostnamesMap{}, want: []regionalHostnameChange{}, }, { name: "multiple changes", - desired: []cloudflare.RegionalHostname{ + desired: []regionalHostname{ { - Hostname: "create.example.com", - RegionKey: "eu", + hostname: "create.example.com", + regionKey: "eu", }, { - Hostname: "update.example.com", - RegionKey: "eu", + hostname: "update.example.com", + regionKey: "eu", }, { - Hostname: "delete.example.com", - RegionKey: "", + hostname: "delete.example.com", + regionKey: "", }, { - Hostname: "nochange.example.com", - RegionKey: "us", + hostname: "nochange.example.com", + regionKey: "us", }, { - Hostname: "absent.example.com", - RegionKey: "", + hostname: "absent.example.com", + regionKey: "", }, }, - regionalHostnames: RegionalHostnamesMap{ - "update.example.com": cloudflare.RegionalHostname{ - Hostname: "update.example.com", - RegionKey: "us", + regionalHostnames: regionalHostnamesMap{ + "update.example.com": regionalHostname{ + hostname: "update.example.com", + regionKey: "us", }, - "delete.example.com": cloudflare.RegionalHostname{ - Hostname: "delete.example.com", - RegionKey: "ap", + "delete.example.com": regionalHostname{ + hostname: "delete.example.com", + regionKey: "ap", }, - "nochange.example.com": cloudflare.RegionalHostname{ - Hostname: "nochange.example.com", - RegionKey: "us", + "nochange.example.com": regionalHostname{ + hostname: "nochange.example.com", + regionKey: "us", }, }, want: []regionalHostnameChange{ { action: cloudFlareCreate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "create.example.com", - RegionKey: "eu", + regionalHostname: regionalHostname{ + hostname: "create.example.com", + regionKey: "eu", }, }, { action: cloudFlareUpdate, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "update.example.com", - RegionKey: "eu", + regionalHostname: regionalHostname{ + hostname: "update.example.com", + regionKey: "eu", }, }, { action: cloudFlareDelete, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "delete.example.com", - RegionKey: "", + regionalHostname: regionalHostname{ + hostname: "delete.example.com", + regionKey: "", }, }, }, @@ -834,7 +845,7 @@ func TestApplyChangesWithRegionalHostnamesFaillures(t *testing.T) { t.Parallel() type fields struct { Records map[string]cloudflare.DNSRecord - RegionalHostnames []cloudflare.RegionalHostname + RegionalHostnames []regionalHostname RegionKey string } type args struct { @@ -869,7 +880,7 @@ func TestApplyChangesWithRegionalHostnamesFaillures(t *testing.T) { name: "create fails", fields: fields{ Records: map[string]cloudflare.DNSRecord{}, - RegionalHostnames: []cloudflare.RegionalHostname{}, + RegionalHostnames: []regionalHostname{}, RegionKey: "us", }, args: args{ @@ -899,8 +910,8 @@ func TestApplyChangesWithRegionalHostnamesFaillures(t *testing.T) { Content: "127.0.0.1", }, }, - RegionalHostnames: []cloudflare.RegionalHostname{ - {Hostname: "rherror.bar.com", RegionKey: "us"}, + RegionalHostnames: []regionalHostname{ + {hostname: "rherror.bar.com", regionKey: "us"}, }, RegionKey: "us", }, @@ -941,8 +952,8 @@ func TestApplyChangesWithRegionalHostnamesFaillures(t *testing.T) { Content: "127.0.0.1", }, }, - RegionalHostnames: []cloudflare.RegionalHostname{ - {Hostname: "rherror.bar.com", RegionKey: "us"}, + RegionalHostnames: []regionalHostname{ + {hostname: "rherror.bar.com", regionKey: "us"}, }, RegionKey: "us", }, @@ -964,7 +975,7 @@ func TestApplyChangesWithRegionalHostnamesFaillures(t *testing.T) { name: "conflicting regional keys", fields: fields{ Records: map[string]cloudflare.DNSRecord{}, - RegionalHostnames: []cloudflare.RegionalHostname{}, + RegionalHostnames: []regionalHostname{}, RegionKey: "us", }, args: args{ @@ -1008,7 +1019,7 @@ func TestApplyChangesWithRegionalHostnamesFaillures(t *testing.T) { Records: map[string]map[string]cloudflare.DNSRecord{ "001": records, }, - regionalHostnames: map[string][]cloudflare.RegionalHostname{ + regionalHostnames: map[string][]regionalHostname{ "001": tt.fields.RegionalHostnames, }, }, @@ -1034,7 +1045,7 @@ func TestApplyChangesWithRegionalHostnamesDryRun(t *testing.T) { t.Parallel() type fields struct { Records map[string]cloudflare.DNSRecord - RegionalHostnames []cloudflare.RegionalHostname + RegionalHostnames []regionalHostname RegionKey string } type args struct { @@ -1050,7 +1061,7 @@ func TestApplyChangesWithRegionalHostnamesDryRun(t *testing.T) { name: "create dry run", fields: fields{ Records: map[string]cloudflare.DNSRecord{}, - RegionalHostnames: []cloudflare.RegionalHostname{}, + RegionalHostnames: []regionalHostname{}, RegionKey: "us", }, args: args{ @@ -1080,8 +1091,8 @@ func TestApplyChangesWithRegionalHostnamesDryRun(t *testing.T) { Content: "127.0.0.1", }, }, - RegionalHostnames: []cloudflare.RegionalHostname{ - {Hostname: "foo.bar.com", RegionKey: "us"}, + RegionalHostnames: []regionalHostname{ + {hostname: "foo.bar.com", regionKey: "us"}, }, RegionKey: "us", }, @@ -1122,8 +1133,8 @@ func TestApplyChangesWithRegionalHostnamesDryRun(t *testing.T) { Content: "127.0.0.1", }, }, - RegionalHostnames: []cloudflare.RegionalHostname{ - {Hostname: "foo.bar.com", RegionKey: "us"}, + RegionalHostnames: []regionalHostname{ + {hostname: "foo.bar.com", regionKey: "us"}, }, RegionKey: "us", }, @@ -1157,7 +1168,7 @@ func TestApplyChangesWithRegionalHostnamesDryRun(t *testing.T) { Records: map[string]map[string]cloudflare.DNSRecord{ "001": records, }, - regionalHostnames: map[string][]cloudflare.RegionalHostname{ + regionalHostnames: map[string][]regionalHostname{ "001": tt.fields.RegionalHostnames, }, }, diff --git a/provider/cloudflare/cloudflare_test.go b/provider/cloudflare/cloudflare_test.go index b304d2116..ccc3d005c 100644 --- a/provider/cloudflare/cloudflare_test.go +++ b/provider/cloudflare/cloudflare_test.go @@ -27,9 +27,12 @@ import ( "testing" "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/cloudflare-go/v4/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" "sigs.k8s.io/external-dns/plan" @@ -48,19 +51,18 @@ type MockAction struct { ZoneId string RecordId string RecordData cloudflare.DNSRecord - RegionalHostname cloudflare.RegionalHostname + RegionalHostname regionalHostname } type mockCloudFlareClient struct { - Zones map[string]string - Records map[string]map[string]cloudflare.DNSRecord - Actions []MockAction - listZonesError error - zoneDetailsError error - listZonesContextError error - dnsRecordsError error - customHostnames map[string][]cloudflare.CustomHostname - regionalHostnames map[string][]cloudflare.RegionalHostname + Zones map[string]string + Records map[string]map[string]cloudflare.DNSRecord + Actions []MockAction + listZonesError error // For v4 ListZones + getZoneError error // For v4 GetZone + dnsRecordsError error + customHostnames map[string][]cloudflare.CustomHostname + regionalHostnames map[string][]regionalHostname } var ExampleDomain = []cloudflare.DNSRecord{ @@ -102,7 +104,7 @@ func NewMockCloudFlareClient() *mockCloudFlareClient { "002": {}, }, customHostnames: map[string][]cloudflare.CustomHostname{}, - regionalHostnames: map[string][]cloudflare.RegionalHostname{}, + regionalHostnames: map[string][]regionalHostname{}, } } @@ -335,54 +337,60 @@ func (m *mockCloudFlareClient) DeleteCustomHostname(ctx context.Context, zoneID } 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 } } - return "", errors.New("Unknown zone: " + zoneName) + // 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) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { - if m.listZonesContextError != nil { - return cloudflare.ZonesResponse{}, m.listZonesContextError +// V4 Zone methods +func (m *mockCloudFlareClient) ListZones(ctx context.Context, params zones.ZoneListParams) autoPager[zones.Zone] { + if m.listZonesError != nil { + return &mockAutoPager[zones.Zone]{ + err: m.listZonesError, + } } - result := []cloudflare.Zone{} + var results []zones.Zone - for zoneId, zoneName := range m.Zones { - result = append(result, cloudflare.Zone{ - ID: zoneId, + 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 cloudflare.ZonesResponse{ - Result: result, - ResultInfo: cloudflare.ResultInfo{ - Page: 1, - TotalPages: 1, - }, - }, nil + return &mockAutoPager[zones.Zone]{ + items: results, + } } -func (m *mockCloudFlareClient) ZoneDetails(ctx context.Context, zoneID string) (cloudflare.Zone, error) { - if m.zoneDetailsError != nil { - return cloudflare.Zone{}, m.zoneDetailsError +func (m *mockCloudFlareClient) GetZone(ctx 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 cloudflare.Zone{ + return &zones.Zone{ ID: zoneID, Name: zoneName, - Plan: cloudflare.ZonePlan{IsSubscribed: strings.HasSuffix(zoneName, "bar.com")}, + Plan: zones.ZonePlan{IsSubscribed: strings.HasSuffix(zoneName, "bar.com")}, //nolint:SA1019 // Plan.IsSubscribed is deprecated but no replacement available yet }, nil } } - return cloudflare.Zone{}, errors.New("Unknown zoneID: " + zoneID) + return nil, errors.New("Unknown zoneID: " + zoneID) } func getCustomHostnameIdxByID(chs []cloudflare.CustomHostname, customHostnameID string) int { @@ -841,7 +849,7 @@ func TestCloudflareZones(t *testing.T) { func TestCloudflareZonesFailed(t *testing.T) { client := NewMockCloudFlareClient() - client.zoneDetailsError = errors.New("zone lookup failed") + client.getZoneError = errors.New("zone lookup failed") provider := &CloudFlareProvider{ Client: client, @@ -877,7 +885,7 @@ func TestCloudFlareZonesWithIDFilter(t *testing.T) { func TestCloudflareListZonesRateLimited(t *testing.T) { // Create a mock client that returns a rate limit error client := NewMockCloudFlareClient() - client.listZonesContextError = &cloudflare.Error{ + client.listZonesError = &cloudflare.Error{ StatusCode: 429, ErrorCodes: []int{10000}, Type: cloudflare.ErrorTypeRateLimit, @@ -896,7 +904,7 @@ func TestCloudflareListZonesRateLimited(t *testing.T) { func TestCloudflareListZonesRateLimitedStringError(t *testing.T) { // Create a mock client that returns a rate limit error client := NewMockCloudFlareClient() - client.listZonesContextError = errors.New("exceeded available rate limit retries") + client.listZonesError = errors.New("exceeded available rate limit retries") p := &CloudFlareProvider{Client: client} // Call the Zones function @@ -909,7 +917,7 @@ func TestCloudflareListZonesRateLimitedStringError(t *testing.T) { func TestCloudflareListZoneInternalErrors(t *testing.T) { // Create a mock client that returns a internal server error client := NewMockCloudFlareClient() - client.listZonesContextError = &cloudflare.Error{ + client.listZonesError = &cloudflare.Error{ StatusCode: 500, ErrorCodes: []int{20000}, Type: cloudflare.ErrorTypeService, @@ -949,7 +957,7 @@ func TestCloudflareRecords(t *testing.T) { t.Errorf("expected to fail") } client.dnsRecordsError = nil - client.listZonesContextError = &cloudflare.Error{ + client.listZonesError = &cloudflare.Error{ StatusCode: 429, ErrorCodes: []int{10000}, Type: cloudflare.ErrorTypeRateLimit, @@ -960,7 +968,7 @@ func TestCloudflareRecords(t *testing.T) { t.Error("expected a rate limit error") } - client.listZonesContextError = &cloudflare.Error{ + client.listZonesError = &cloudflare.Error{ StatusCode: 500, ErrorCodes: []int{10000}, Type: cloudflare.ErrorTypeService, @@ -971,7 +979,7 @@ func TestCloudflareRecords(t *testing.T) { t.Error("expected a internal server error") } - client.listZonesContextError = errors.New("failed to list zones") + client.listZonesError = errors.New("failed to list zones") _, err = p.Records(ctx) if err == nil { t.Errorf("expected to fail") @@ -1795,8 +1803,8 @@ func TestCloudFlareProvider_newCloudFlareChange(t *testing.T) { } change, _ := p.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) + if change.RegionalHostname.regionKey != "us" { + t.Errorf("expected region key to be 'us', but got '%s'", change.RegionalHostname.regionKey) } var freeValidCommentBuilder strings.Builder @@ -1949,8 +1957,8 @@ func TestCloudFlareProvider_submitChangesCNAME(t *testing.T) { ID: "1234567890", Content: "my-tunnel-guid-here.cfargotunnel.com", }, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "my-domain-here.app", + RegionalHostname: regionalHostname{ + hostname: "my-domain-here.app", }, }, { @@ -1961,9 +1969,9 @@ func TestCloudFlareProvider_submitChangesCNAME(t *testing.T) { ID: "9876543210", Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app", }, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "my-domain-here.app", - RegionKey: "", + RegionalHostname: regionalHostname{ + hostname: "my-domain-here.app", + regionKey: "", }, }, } @@ -2012,8 +2020,8 @@ func TestCloudFlareProvider_submitChangesApex(t *testing.T) { ID: "1234567890", Content: "my-tunnel-guid-here.cfargotunnel.com", }, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "@", // APEX record + RegionalHostname: regionalHostname{ + hostname: "@", // APEX record }, }, { @@ -2024,9 +2032,9 @@ func TestCloudFlareProvider_submitChangesApex(t *testing.T) { ID: "9876543210", Content: "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/external-dns/my-domain-here-app", }, - RegionalHostname: cloudflare.RegionalHostname{ - Hostname: "@", // APEX record - RegionKey: "", + RegionalHostname: regionalHostname{ + hostname: "@", // APEX record + regionKey: "", }, }, } @@ -2584,7 +2592,7 @@ func TestZoneHasPaidPlan(t *testing.T) { assert.True(t, cfprovider.ZoneHasPaidPlan("subdomain.bar.com")) assert.False(t, cfprovider.ZoneHasPaidPlan("invaliddomain")) - client.zoneDetailsError = errors.New("zone lookup failed") + client.getZoneError = errors.New("zone lookup failed") cfproviderWithZoneError := &CloudFlareProvider{ Client: client, domainFilter: endpoint.NewDomainFilter([]string{"foo.com", "bar.com"}), @@ -2592,6 +2600,7 @@ func TestZoneHasPaidPlan(t *testing.T) { } assert.False(t, cfproviderWithZoneError.ZoneHasPaidPlan("subdomain.foo.com")) } + func TestCloudflareApplyChanges_AllErrorLogPaths(t *testing.T) { hook := testutils.LogsUnderTestWithLogLevel(log.ErrorLevel, t) @@ -2760,3 +2769,488 @@ func TestCloudFlareProvider_SupportedAdditionalRecordTypes(t *testing.T) { }) } } + +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(context.Background()) + 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(context.Background()) + 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: cloudflare.DNSRecord{Name: "test1.foo.com", Type: "A", Content: "1.2.3.4"}, + }, + { + Action: cloudFlareCreate, + ResourceRecord: cloudflare.DNSRecord{Name: "test2.foo.com", Type: "A", Content: "1.2.3.5"}, + }, + { + Action: cloudFlareCreate, + ResourceRecord: cloudflare.DNSRecord{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(context.Background()) + 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(context.Background()) + 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(context.Background()) + 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(context.Background()) + 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(context.Background()) + 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: cloudflare.DNSRecord{Name: "api.foo.com", Type: "A", Content: "1.2.3.4"}, + }, + { + Action: cloudFlareUpdate, + ResourceRecord: cloudflare.DNSRecord{Name: "www.foo.com", Type: "CNAME", Content: "foo.com"}, + }, + { + Action: cloudFlareCreate, + ResourceRecord: cloudflare.DNSRecord{Name: "mail.bar.com", Type: "MX", Content: "10 mail.bar.com"}, + }, + { + Action: cloudFlareDelete, + ResourceRecord: cloudflare.DNSRecord{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: &cloudflare.Error{StatusCode: 429, Type: cloudflare.ErrorTypeRateLimit}, + expectSoftError: true, + description: "CloudFlare API rate limit error should be converted to soft error", + }, + { + name: "Rate limit error via ClientRateLimited", + inputError: &cloudflare.Error{StatusCode: 429, ErrorCodes: []int{10000}, Type: cloudflare.ErrorTypeRateLimit}, // Complete rate limit error + expectSoftError: true, + description: "CloudFlare client rate limited error should be converted to soft error", + }, + { + name: "Server error 500", + inputError: &cloudflare.Error{StatusCode: 500}, + expectSoftError: true, + description: "Server error (500+) should be converted to soft error", + }, + { + name: "Server error 502", + inputError: &cloudflare.Error{StatusCode: 502}, + expectSoftError: true, + description: "Server error (502) should be converted to soft error", + }, + { + name: "Server error 503", + inputError: &cloudflare.Error{StatusCode: 503}, + expectSoftError: true, + description: "Server error (503) 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: &cloudflare.Error{StatusCode: 400}, + expectSoftError: false, + description: "Client error (400) should not be converted to soft error", + }, + { + name: "Client error 401", + inputError: &cloudflare.Error{StatusCode: 401}, + expectSoftError: false, + description: "Client error (401) should not be converted to soft error", + }, + { + name: "Client error 404", + inputError: &cloudflare.Error{StatusCode: 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 the original error message is preserved in the soft error + 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 = &cloudflare.Error{StatusCode: 429, Type: cloudflare.ErrorTypeRateLimit} + }, + function: func(p *CloudFlareProvider) error { + p.zoneIDFilter.ZoneIDs = []string{"zone1"} + _, err := p.Zones(context.Background()) + 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 = &cloudflare.Error{StatusCode: 500} + }, + function: func(p *CloudFlareProvider) error { + p.zoneIDFilter.ZoneIDs = []string{"zone1"} + _, err := p.Zones(context.Background()) + 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 = &cloudflare.Error{StatusCode: 404} + }, + function: func(p *CloudFlareProvider) error { + p.zoneIDFilter.ZoneIDs = []string{"zone1"} + _, err := p.Zones(context.Background()) + 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(context.Background()) + 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 = &cloudflare.Error{StatusCode: 503} + }, + function: func(p *CloudFlareProvider) error { + _, err := p.Zones(context.Background()) + 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) { + // Set required environment variables for CloudFlare provider + t.Setenv("CF_API_TOKEN", "test-token") + + client := NewMockCloudFlareClient() + + // 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, err := NewCloudFlareProvider( + domainFilter, + provider.NewZoneIDFilter([]string{""}), // empty zone ID filter so it uses ListZones path + false, // proxied + false, // dry run + RegionalServicesConfig{}, + CustomHostnamesConfig{}, + DNSRecordsConfig{PerPage: 50}, + ) + require.NoError(t, err) + + // Replace the real client with our mock + p.Client = client + + // Capture debug logs to verify the filter log message + oldLevel := log.GetLevel() + log.SetLevel(log.DebugLevel) + defer log.SetLevel(oldLevel) + + // Use a custom formatter to capture log output + var logOutput strings.Builder + log.SetOutput(&logOutput) + defer log.SetOutput(os.Stderr) + + // Call Zones() which should trigger the domain filter logic + zones, err := p.Zones(context.Background()) + 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 + logString := logOutput.String() + assert.Contains(t, logString, `zone \"foo.com\" not in domain filter`) + assert.Contains(t, logString, "no zoneIDFilter configured, looking at all zones") +} + +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") +} diff --git a/provider/cloudflare/pagination.go b/provider/cloudflare/pagination.go new file mode 100644 index 000000000..2b8337ae2 --- /dev/null +++ b/provider/cloudflare/pagination.go @@ -0,0 +1,34 @@ +/* +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 + } + } + } +} diff --git a/provider/cloudflare/pagination_test.go b/provider/cloudflare/pagination_test.go new file mode 100644 index 000000000..1f0c28e82 --- /dev/null +++ b/provider/cloudflare/pagination_test.go @@ -0,0 +1,94 @@ +/* +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) + }) +} diff --git a/provider/digitalocean/digital_ocean_test.go b/provider/digitalocean/digital_ocean_test.go index 18f62aeb4..e174fe067 100644 --- a/provider/digitalocean/digital_ocean_test.go +++ b/provider/digitalocean/digital_ocean_test.go @@ -194,7 +194,7 @@ func isEmpty(xs interface{}) bool { // 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 interface{}, msgAndArgs ...interface{}) (ok bool) { +func elementsMatch(t *testing.T, listA, listB interface{}, msgAndArgs ...interface{}) bool { if listA == nil && listB == nil { return true } else if listA == nil { diff --git a/provider/dnsimple/dnsimple.go b/provider/dnsimple/dnsimple.go index 5a0ee365b..5d120e67a 100644 --- a/provider/dnsimple/dnsimple.go +++ b/provider/dnsimple/dnsimple.go @@ -130,7 +130,7 @@ func NewDnsimpleProvider(domainFilter *endpoint.DomainFilter, zoneIDFilter provi } // GetAccountID returns the account ID given DNSimple credentials. -func (p *dnsimpleProvider) GetAccountID(ctx context.Context) (accountID string, err error) { +func (p *dnsimpleProvider) GetAccountID(ctx context.Context) (string, error) { // get DNSimple client accountID whoamiResponse, err := p.identity.Whoami(ctx) if err != nil { @@ -191,11 +191,12 @@ func (p *dnsimpleProvider) Zones(ctx context.Context) (map[string]dnsimple.Zone, } // Records returns a list of endpoints in a given zone -func (p *dnsimpleProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { +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{} @@ -318,7 +319,7 @@ func (p *dnsimpleProvider) submitChanges(ctx context.Context, changes []*dnsimpl } // GetRecordID returns the record ID for a given record name and zone. -func (p *dnsimpleProvider) GetRecordID(ctx context.Context, zone string, recordName string) (recordID int64, err error) { +func (p *dnsimpleProvider) GetRecordID(ctx context.Context, zone string, recordName string) (int64, error) { page := 1 listOptions := &dnsimple.ZoneRecordListOptions{Name: &recordName} for { diff --git a/provider/exoscale/exoscale.go b/provider/exoscale/exoscale.go index 195d7cca0..86c40388b 100644 --- a/provider/exoscale/exoscale.go +++ b/provider/exoscale/exoscale.go @@ -292,9 +292,8 @@ func (f *zoneFilter) Zones(zones map[string]string) map[string]string { // 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) (zoneID string, name string) { - var matchZoneID string - var matchZoneName string +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 diff --git a/provider/gandi/client.go b/provider/gandi/client.go index a5e55334f..049018343 100644 --- a/provider/gandi/client.go +++ b/provider/gandi/client.go @@ -19,14 +19,14 @@ import ( ) type DomainClientAdapter interface { - ListDomains() (domains []domain.ListResponse, err error) + ListDomains() ([]domain.ListResponse, error) } type domainClient struct { Client *domain.Domain } -func (p *domainClient) ListDomains() (domains []domain.ListResponse, err error) { +func (p *domainClient) ListDomains() ([]domain.ListResponse, error) { return p.Client.ListDomains() } @@ -54,9 +54,9 @@ type standardError struct { type LiveDNSClientAdapter interface { GetDomainRecords(fqdn string) (records []livedns.DomainRecord, err error) - CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (response standardResponse, 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) (response standardResponse, err error) + UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) } type LiveDNSClient struct { @@ -67,11 +67,11 @@ func NewLiveDNSClient(client *livedns.LiveDNS) LiveDNSClientAdapter { return &LiveDNSClient{client} } -func (p *LiveDNSClient) GetDomainRecords(fqdn string) (records []livedns.DomainRecord, err error) { +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) (response standardResponse, err error) { +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 @@ -93,11 +93,11 @@ func (p *LiveDNSClient) CreateDomainRecord(fqdn, name, recordtype string, ttl in }, err } -func (p *LiveDNSClient) DeleteDomainRecord(fqdn, name, recordtype string) (err error) { +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) (response standardResponse, err error) { +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 diff --git a/provider/gandi/gandi.go b/provider/gandi/gandi.go index fd7234157..e8fdea337 100644 --- a/provider/gandi/gandi.go +++ b/provider/gandi/gandi.go @@ -83,12 +83,12 @@ func NewGandiProvider(ctx context.Context, domainFilter *endpoint.DomainFilter, return gandiProvider, nil } -func (p *GandiProvider) Zones() (zones []string, err error) { +func (p *GandiProvider) Zones() ([]string, error) { availableDomains, err := p.DomainClient.ListDomains() if err != nil { return nil, err } - zones = []string{} + zones := []string{} for _, domain := range availableDomains { if !p.domainFilter.Match(domain.FQDN) { log.Debugf("Excluding domain %s by domain-filter", domain.FQDN) @@ -156,7 +156,7 @@ func (p *GandiProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) return p.submitChanges(ctx, combinedChanges) } -func (p *GandiProvider) submitChanges(ctx context.Context, changes []*GandiChanges) error { +func (p *GandiProvider) submitChanges(_ context.Context, changes []*GandiChanges) error { if len(changes) == 0 { log.Infof("All records are already up to date") return nil diff --git a/provider/gandi/gandi_test.go b/provider/gandi/gandi_test.go index 72c73aeb4..8e564b8e1 100644 --- a/provider/gandi/gandi_test.go +++ b/provider/gandi/gandi_test.go @@ -49,7 +49,7 @@ const ( // Mock all methods -func (m *mockGandiClient) GetDomainRecords(fqdn string) (records []livedns.DomainRecord, err error) { +func (m *mockGandiClient) GetDomainRecords(fqdn string) ([]livedns.DomainRecord, error) { m.Actions = append(m.Actions, MockAction{ Name: "GetDomainRecords", FQDN: fqdn, @@ -62,7 +62,7 @@ func (m *mockGandiClient) GetDomainRecords(fqdn string) (records []livedns.Domai return m.RecordsToReturn, nil } -func (m *mockGandiClient) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (response standardResponse, err error) { +func (m *mockGandiClient) CreateDomainRecord(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) { m.Actions = append(m.Actions, MockAction{ Name: "CreateDomainRecord", FQDN: fqdn, @@ -81,7 +81,7 @@ func (m *mockGandiClient) CreateDomainRecord(fqdn, name, recordtype string, ttl return standardResponse{}, nil } -func (m *mockGandiClient) DeleteDomainRecord(fqdn, name, recordtype string) (err error) { +func (m *mockGandiClient) DeleteDomainRecord(fqdn, name, recordtype string) error { m.Actions = append(m.Actions, MockAction{ Name: "DeleteDomainRecord", FQDN: fqdn, @@ -98,7 +98,7 @@ func (m *mockGandiClient) DeleteDomainRecord(fqdn, name, recordtype string) (err return nil } -func (m *mockGandiClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (response standardResponse, err error) { +func (m *mockGandiClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype string, ttl int, values []string) (standardResponse, error) { m.Actions = append(m.Actions, MockAction{ Name: "UpdateDomainRecordByNameAndType", FQDN: fqdn, @@ -117,7 +117,7 @@ func (m *mockGandiClient) UpdateDomainRecordByNameAndType(fqdn, name, recordtype return standardResponse{}, nil } -func (m *mockGandiClient) ListDomains() (domains []domain.ListResponse, err error) { +func (m *mockGandiClient) ListDomains() ([]domain.ListResponse, error) { m.Actions = append(m.Actions, MockAction{ Name: "ListDomains", }) diff --git a/provider/godaddy/godaddy.go b/provider/godaddy/godaddy.go index 3b318824e..045cd2332 100644 --- a/provider/godaddy/godaddy.go +++ b/provider/godaddy/godaddy.go @@ -121,7 +121,9 @@ func (z gdZoneIDName) add(zoneID string, zoneRecord *gdRecords) { z[zoneID] = zoneRecord } -func (z gdZoneIDName) findZoneRecord(hostname string) (suitableZoneID string, suitableZoneRecord *gdRecords) { +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) { @@ -131,11 +133,11 @@ func (z gdZoneIDName) findZoneRecord(hostname string) (suitableZoneID string, su } } - return + return suitableZoneID, suitableZoneRecord } // NewGoDaddyProvider initializes a new GoDaddy DNS based Provider. -func NewGoDaddyProvider(ctx context.Context, domainFilter *endpoint.DomainFilter, ttl int64, apiKey, apiSecret string, useOTE, dryRun bool) (*GDProvider, error) { +func NewGoDaddyProvider(_ context.Context, 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 @@ -218,7 +220,7 @@ func (p *GDProvider) zonesRecords(ctx context.Context, all bool) ([]string, []gd return zones, allRecords, nil } -func (p *GDProvider) records(ctx *context.Context, zone string, all bool) (*gdRecords, error) { +func (p *GDProvider) records(_ *context.Context, zone string, all bool) (*gdRecords, error) { var recordsIds []gdRecordField log.Debugf("GoDaddy: Getting records for %s", zone) diff --git a/provider/google/google.go b/provider/google/google.go index f2897d9f5..fd521fc0e 100644 --- a/provider/google/google.go +++ b/provider/google/google.go @@ -20,17 +20,17 @@ import ( "context" "fmt" "sort" - "strings" "time" "cloud.google.com/go/compute/metadata" - "github.com/linki/instrumented_http" 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/plan" "sigs.k8s.io/external-dns/provider" @@ -131,12 +131,7 @@ func NewGoogleProvider(ctx context.Context, project string, domainFilter *endpoi return nil, err } - gcloud = instrumented_http.NewClient(gcloud, &instrumented_http.Callbacks{ - PathProcessor: func(path string) string { - parts := strings.Split(path, "/") - return parts[len(parts)-1] - }, - }) + gcloud = extdnshttp.NewInstrumentedClient(gcloud) dnsClient, err := dns.NewService(ctx, option.WithHTTPClient(gcloud)) if err != nil { @@ -207,12 +202,14 @@ func (p *GoogleProvider) Zones(ctx context.Context) (map[string]*dns.ManagedZone } // Records returns the list of records in all relevant zones. -func (p *GoogleProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { +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) { @@ -226,7 +223,7 @@ func (p *GoogleProvider) Records(ctx context.Context) (endpoints []*endpoint.End for _, z := range zones { if err := p.resourceRecordSetsClient.List(p.project, z.Name).Pages(ctx, f); err != nil { - return nil, provider.NewSoftError(fmt.Errorf("failed to list records in zone %s: %w", z.Name, err)) + return nil, provider.NewSoftErrorf("failed to list records in zone %s: %v", z.Name, err) } } diff --git a/provider/inmemory/inmemory.go b/provider/inmemory/inmemory.go index e1d01baff..b17ef900e 100644 --- a/provider/inmemory/inmemory.go +++ b/provider/inmemory/inmemory.go @@ -230,7 +230,7 @@ func (f *filter) Zones(zones map[string]string) map[string]string { // 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) (zoneID string) { +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) { diff --git a/provider/oci/oci_test.go b/provider/oci/oci_test.go index aeb27b8fb..a7c914cd8 100644 --- a/provider/oci/oci_test.go +++ b/provider/oci/oci_test.go @@ -70,7 +70,7 @@ func buildZoneResponseItems(scope dns.ListZonesScopeEnum, privateZones, globalZo } } -func (c *mockOCIDNSClient) ListZones(_ context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) { +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}), @@ -82,9 +82,11 @@ func (c *mockOCIDNSClient) ListZones(_ context.Context, request dns.ListZonesReq }, nil } -func (c *mockOCIDNSClient) GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) { +func (c *mockOCIDNSClient) GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (dns.GetZoneRecordsResponse, error) { + var response dns.GetZoneRecordsResponse + var err error if request.ZoneNameOrId == nil { - return + return response, err } switch *request.ZoneNameOrId { @@ -120,12 +122,11 @@ func (c *mockOCIDNSClient) GetZoneRecords(ctx context.Context, request dns.GetZo }} } } - - return + return response, err } -func (c *mockOCIDNSClient) PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) { - return // Provider does not use the response so nothing to do here. +func (c *mockOCIDNSClient) PatchZoneRecords(_ context.Context, request dns.PatchZoneRecordsRequest) (dns.PatchZoneRecordsResponse, error) { + return dns.PatchZoneRecordsResponse{}, nil } // newOCIProvider creates an OCI provider with API calls mocked out. @@ -549,7 +550,7 @@ func newMutableMockOCIDNSClient(zones []dns.ZoneSummary, recordsByZone map[strin return c } -func (c *mutableMockOCIDNSClient) ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) { +func (c *mutableMockOCIDNSClient) ListZones(_ context.Context, _ dns.ListZonesRequest) (dns.ListZonesResponse, error) { var zones []dns.ZoneSummary for _, v := range c.zones { zones = append(zones, v) @@ -557,16 +558,15 @@ func (c *mutableMockOCIDNSClient) ListZones(ctx context.Context, request dns.Lis return dns.ListZonesResponse{Items: zones}, nil } -func (c *mutableMockOCIDNSClient) GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) { +func (c *mutableMockOCIDNSClient) GetZoneRecords(_ context.Context, request dns.GetZoneRecordsRequest) (dns.GetZoneRecordsResponse, error) { + var response dns.GetZoneRecordsResponse if request.ZoneNameOrId == nil { - err = errors.New("no name or id") - return + return response, errors.New("no name or id") } records, ok := c.records[*request.ZoneNameOrId] if !ok { - err = errors.New("zone not found") - return + return response, errors.New("zone not found") } var items []dns.Record @@ -575,7 +575,7 @@ func (c *mutableMockOCIDNSClient) GetZoneRecords(ctx context.Context, request dn } response.Items = items - return + return response, nil } func ociRecordKey(rType, domain string, ip string) string { @@ -592,16 +592,15 @@ func sortEndpointTargets(endpoints []*endpoint.Endpoint) { } } -func (c *mutableMockOCIDNSClient) PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) { +func (c *mutableMockOCIDNSClient) PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (dns.PatchZoneRecordsResponse, error) { + var response dns.PatchZoneRecordsResponse if request.ZoneNameOrId == nil { - err = errors.New("no name or id") - return + return response, errors.New("no name or id") } records, ok := c.records[*request.ZoneNameOrId] if !ok { - err = errors.New("zone not found") - return + return response, errors.New("zone not found") } // Ensure that ADD operations occur after REMOVE. @@ -622,11 +621,10 @@ func (c *mutableMockOCIDNSClient) PatchZoneRecords(ctx context.Context, request case dns.RecordOperationOperationRemove: delete(records, k) default: - err = fmt.Errorf("unsupported operation %q", op.Operation) - return + return response, fmt.Errorf("unsupported operation %q", op.Operation) } } - return + return response, nil } // TestMutableMockOCIDNSClient exists because one must always test one's tests diff --git a/provider/ovh/ovh.go b/provider/ovh/ovh.go index 48d3125f8..d3c696ad6 100644 --- a/provider/ovh/ovh.go +++ b/provider/ovh/ovh.go @@ -242,7 +242,7 @@ func (p *OVHProvider) handleSingleZoneUpdate(ctx context.Context, zoneName strin } // ApplyChanges applies a given set of changes in a given zone. -func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) (err error) { +func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { zones, records := p.lastRunZones, p.lastRunRecords defer func() { p.lastRunRecords = []ovhRecord{} diff --git a/provider/pdns/pdns.go b/provider/pdns/pdns.go index f1916f817..748e220ed 100644 --- a/provider/pdns/pdns.go +++ b/provider/pdns/pdns.go @@ -114,15 +114,14 @@ func (tlsConfig *TLSConfig) setHTTPClient(pdnsClientConfig *pgo.Configuration) e } // Function for debug printing -func stringifyHTTPResponseBody(r *http.Response) (body string) { +func stringifyHTTPResponseBody(r *http.Response) string { if r == nil { return "" } buf := new(bytes.Buffer) - buf.ReadFrom(r.Body) - body = buf.String() - return body + _, _ = buf.ReadFrom(r.Body) + return buf.String() } // PDNSAPIProvider : Interface used and extended by the PDNSAPIClient struct as @@ -145,7 +144,10 @@ type PDNSAPIClient struct { // 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() (zones []pgo.Zone, resp *http.Response, err error) { +func (c *PDNSAPIClient) ListZones() ([]pgo.Zone, *http.Response, error) { + var zones []pgo.Zone + var resp *http.Response + var err error for i := 0; i < retryLimit; i++ { zones, resp, err = c.client.ZonesApi.ListZones(c.authCtx, c.serverID) if err != nil { @@ -157,11 +159,14 @@ func (c *PDNSAPIClient) ListZones() (zones []pgo.Zone, resp *http.Response, err return zones, resp, err } - return zones, resp, provider.NewSoftError(fmt.Errorf("unable to list zones: %w", err)) + return zones, resp, provider.NewSoftErrorf("unable to list zones: %v", err) } // PartitionZones : Method returns a slice of zones that adhere to the domain filter and a slice of ones that does not adhere to the filter -func (c *PDNSAPIClient) PartitionZones(zones []pgo.Zone) (filteredZones []pgo.Zone, residualZones []pgo.Zone) { +func (c *PDNSAPIClient) PartitionZones(zones []pgo.Zone) ([]pgo.Zone, []pgo.Zone) { + var filteredZones []pgo.Zone + var residualZones []pgo.Zone + if c.domainFilter.IsConfigured() { for _, zone := range zones { if c.domainFilter.Match(zone.Name) { @@ -178,9 +183,9 @@ func (c *PDNSAPIClient) PartitionZones(zones []pgo.Zone) (filteredZones []pgo.Zo // 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) (zone pgo.Zone, resp *http.Response, err error) { +func (c *PDNSAPIClient) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { for i := 0; i < retryLimit; i++ { - zone, resp, err = c.client.ZonesApi.ListZone(c.authCtx, c.serverID, zoneID) + 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) @@ -190,12 +195,14 @@ func (c *PDNSAPIClient) ListZone(zoneID string) (zone pgo.Zone, resp *http.Respo return zone, resp, err } - return zone, resp, provider.NewSoftError(fmt.Errorf("unable to list zone: %w", 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) (resp *http.Response, err error) { +func (c *PDNSAPIClient) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) { + var resp *http.Response + var err error for i := 0; i < retryLimit; i++ { resp, err = c.client.ZonesApi.PatchZone(c.authCtx, c.serverID, zoneID, zoneStruct) if err != nil { @@ -207,7 +214,7 @@ func (c *PDNSAPIClient) PatchZone(zoneID string, zoneStruct pgo.Zone) (resp *htt return resp, err } - return resp, provider.NewSoftError(fmt.Errorf("unable to patch zone: %w", err)) + return resp, provider.NewSoftErrorf("unable to patch zone: %v", err) } // PDNSProvider is an implementation of the Provider interface for PowerDNS @@ -252,9 +259,9 @@ func NewPDNSProvider(ctx context.Context, config PDNSConfig) (*PDNSProvider, err return provider, nil } -func (p *PDNSProvider) convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error) { - endpoints = []*endpoint.Endpoint{} - targets := []string{} +func (p *PDNSProvider) convertRRSetToEndpoints(rr pgo.RrSet) ([]*endpoint.Endpoint, error) { + endpoints := make([]*endpoint.Endpoint, 0) + targets := make([]string, 0) rrType_ := rr.Type_ for _, record := range rr.Records { @@ -271,8 +278,8 @@ func (p *PDNSProvider) convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpo } // ConvertEndpointsToZones marshals endpoints into pdns compatible Zone structs -func (p *PDNSProvider) ConvertEndpointsToZones(eps []*endpoint.Endpoint, changetype pdnsChangeType) (zonelist []pgo.Zone, _ error) { - zonelist = []pgo.Zone{} +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) @@ -354,7 +361,7 @@ func (p *PDNSProvider) ConvertEndpointsToZones(eps []*endpoint.Endpoint, changet } } if len(zone.Rrsets) > 0 { - zonelist = append(zonelist, zone) + zoneList = append(zoneList, zone) } } @@ -379,9 +386,9 @@ func (p *PDNSProvider) ConvertEndpointsToZones(eps []*endpoint.Endpoint, changet log.Warnf("No matching zones were found for the following endpoints: %+v", endpoints) } - log.Debugf("Zone List generated from Endpoints: %+v", zonelist) + log.Debugf("Zone List generated from Endpoints: %+v", zoneList) - return zonelist, nil + return zoneList, nil } // mutateRecords takes a list of endpoints and creates, replaces or deletes them based on the changetype @@ -407,17 +414,19 @@ func (p *PDNSProvider) mutateRecords(endpoints []*endpoint.Endpoint, changetype } // Records returns all DNS records controlled by the configured PDNS server (for all zones) -func (p *PDNSProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { +func (p *PDNSProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { zones, _, err := p.client.ListZones() if err != nil { return nil, err } filteredZones, _ := p.client.PartitionZones(zones) + var endpoints []*endpoint.Endpoint + for _, zone := range filteredZones { z, _, err := p.client.ListZone(zone.Id) if err != nil { - return nil, provider.NewSoftError(fmt.Errorf("unable to fetch records: %w", err)) + return nil, provider.NewSoftErrorf("unable to fetch records: %v", err) } for _, rr := range z.Rrsets { diff --git a/provider/pihole/client.go b/provider/pihole/client.go index cccf85237..f133735d7 100644 --- a/provider/pihole/client.go +++ b/provider/pihole/client.go @@ -28,10 +28,11 @@ import ( "net/url" "strings" - "github.com/linki/instrumented_http" 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" ) @@ -71,7 +72,8 @@ func newPiholeClient(cfg PiholeConfig) (piholeAPI, error) { }, }, } - cl := instrumented_http.NewClient(httpClient, &instrumented_http.Callbacks{}) + + cl := extdnshttp.NewInstrumentedClient(httpClient) p := &piholeClient{ cfg: cfg, diff --git a/provider/pihole/clientV6.go b/provider/pihole/clientV6.go index ebaaabd6a..3168b497c 100644 --- a/provider/pihole/clientV6.go +++ b/provider/pihole/clientV6.go @@ -30,9 +30,10 @@ import ( "strconv" "strings" - "github.com/linki/instrumented_http" 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" ) @@ -65,7 +66,7 @@ func newPiholeClientV6(cfg PiholeConfig) (piholeAPI, error) { }, } - cl := instrumented_http.NewClient(httpClient, &instrumented_http.Callbacks{}) + cl := extdnshttp.NewInstrumentedClient(httpClient) p := &piholeClientV6{ cfg: cfg, @@ -143,12 +144,13 @@ func isValidIPv6(ip string) bool { } func (p *piholeClientV6) listRecords(ctx context.Context, rtype string) ([]*endpoint.Endpoint, error) { - out := make([]*endpoint.Endpoint, 0) 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 == ',' @@ -163,17 +165,17 @@ func (p *piholeClientV6) listRecords(ctx context.Context, rtype string) ([]*endp DNSName, Target = recs[1], recs[0] switch rtype { case endpoint.RecordTypeA: - //PiHole return A and AAAA records. Filter to only keep the A records + // PiHole return A and AAAA records. Filter to only keep the A records if !isValidIPv4(Target) { continue } case endpoint.RecordTypeAAAA: - //PiHole return A and AAAA records. Filter to only keep the AAAA records + // PiHole return A and AAAA records. Filter to only keep the AAAA records if !isValidIPv6(Target) { continue } case endpoint.RecordTypeCNAME: - //PiHole return only CNAME records. + // PiHole return only CNAME records. // CNAME format is DNSName,target, ttl? DNSName, Target = recs[0], recs[1] if len(recs) == 3 { // TTL is present @@ -186,7 +188,18 @@ func (p *piholeClientV6) listRecords(ctx context.Context, rtype string) ([]*endp } } - out = append(out, endpoint.NewEndpointWithTTL(DNSName, rtype, Ttl, Target)) + ep := endpoint.NewEndpointWithTTL(DNSName, rtype, Ttl, Target) + + if oldEp, ok := endpoints[DNSName]; ok { + ep.Targets = append(oldEp.Targets, Target) + } + + endpoints[DNSName] = ep + } + + out := make([]*endpoint.Endpoint, 0, len(endpoints)) + for _, ep := range endpoints { + out = append(out, ep) } return out, nil } @@ -272,37 +285,44 @@ func (p *piholeClientV6) apply(ctx context.Context, action string, ep *endpoint. 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]) - // Get the current record if strings.Contains(ep.DNSName, "*") { return provider.NewSoftError(errors.New("UNSUPPORTED: Pihole DNS names cannot return wildcard")) } - switch ep.RecordType { - case endpoint.RecordTypeA, endpoint.RecordTypeAAAA: - apiUrl = p.generateApiUrl(apiUrl, fmt.Sprintf("%s %s", ep.Targets, ep.DNSName)) - case endpoint.RecordTypeCNAME: - if ep.RecordTTL.IsConfigured() { - apiUrl = p.generateApiUrl(apiUrl, fmt.Sprintf("%s,%s,%d", ep.DNSName, ep.Targets, ep.RecordTTL)) - } else { - apiUrl = p.generateApiUrl(apiUrl, fmt.Sprintf("%s,%s", ep.DNSName, ep.Targets)) + 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 } - } - req, err := http.NewRequestWithContext(ctx, action, apiUrl, nil) - if err != nil { - return err - } + log.Infof("%s %s IN %s -> %s", action, ep.DNSName, ep.RecordType, target) - _, err = p.do(req) - if err != nil { - return err + 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 @@ -400,6 +420,14 @@ func (p *piholeClientV6) do(req *http.Request) ([]byte, error) { 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 { diff --git a/provider/pihole/clientV6_test.go b/provider/pihole/clientV6_test.go index 255032dd0..d474f8eba 100644 --- a/provider/pihole/clientV6_test.go +++ b/provider/pihole/clientV6_test.go @@ -23,10 +23,10 @@ import ( "fmt" "net/http" "net/http/httptest" - "strconv" "strings" "testing" + "github.com/google/go-cmp/cmp" "sigs.k8s.io/external-dns/endpoint" ) @@ -192,10 +192,14 @@ func TestListRecordsV6(t *testing.T) { "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" ] } @@ -237,37 +241,70 @@ func TestListRecordsV6(t *testing.T) { } // Ensure A records were parsed correctly - expected := [][]string{ - {"service1.example.com", "192.168.178.33"}, - {"service2.example.com", "192.168.178.34"}, - {"service3.example.com", "192.168.178.34"}, - {"service7.example.com", "192.168.20.3"}, + 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(context.Background(), endpoint.RecordTypeA) if err != nil { t.Fatal(err) } - if len(arecs) != len(expected) { - t.Fatalf("Expected %d A records returned, got: %d", len(expected), len(arecs)) - } - for idx, rec := range arecs { - if rec.DNSName != expected[idx][0] { - t.Error("Got invalid DNS Name:", rec.DNSName, "expected:", expected[idx][0]) - } - if rec.Targets[0] != expected[idx][1] { - t.Error("Got invalid target:", rec.Targets[0], "expected:", expected[idx][1]) + 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 = [][]string{ - {"service4.example.com", "fc00::1:192:168:1:1"}, - {"service5.example.com", "fc00::1:192:168:1:2"}, - {"service6.example.com", "fc00::1:192:168:1:3"}, - {"service7.example.com", "::ffff:192.168.20.3"}, + 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(context.Background(), endpoint.RecordTypeAAAA) if err != nil { @@ -278,20 +315,34 @@ func TestListRecordsV6(t *testing.T) { t.Fatalf("Expected %d AAAA records returned, got: %d", len(expected), len(arecs)) } - for idx, rec := range arecs { - if rec.DNSName != expected[idx][0] { - t.Error("Got invalid DNS Name:", rec.DNSName, "expected:", expected[idx][0]) - } - if rec.Targets[0] != expected[idx][1] { - t.Error("Got invalid target:", rec.Targets[0], "expected:", expected[idx][1]) + 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 = [][]string{ - {"source1.example.com", "target1.domain.com", "1000"}, - {"source2.example.com", "target2.domain.com", "50"}, - {"source3.example.com", "target3.domain.com"}, + 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 @@ -303,17 +354,14 @@ func TestListRecordsV6(t *testing.T) { t.Fatalf("Expected %d CAME records returned, got: %d", len(expected), len(cnamerecs)) } - for idx, rec := range cnamerecs { - if rec.DNSName != expected[idx][0] { - t.Error("Got invalid DNS Name:", rec.DNSName, "expected:", expected[idx][0]) - } - if rec.Targets[0] != expected[idx][1] { - t.Error("Got invalid target:", rec.Targets[0], "expected:", expected[idx][1]) - } - if len(expected[idx]) == 3 { - expectedTTL, _ := strconv.ParseInt(expected[idx][2], 10, 64) - if int64(rec.RecordTTL) != expectedTTL { - t.Error("Got invalid TTL:", rec.RecordTTL, "expected:", expected[idx][2]) + 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) } } } @@ -432,8 +480,34 @@ func TestErrorsV6(t *testing.T) { if len(resp) != 2 { t.Fatal("Expected one records returned, got:", len(resp)) } - if resp[1].RecordTTL != 0 { - t.Fatal("Expected no TTL returned, got:", resp[0].RecordTTL) + + 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) + } } } @@ -717,6 +791,10 @@ func TestCreateRecordV6(t *testing.T) { 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 @@ -748,6 +826,16 @@ func TestCreateRecordV6(t *testing.T) { 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(context.Background(), ep); err != nil { + t.Fatal(err) + } + // Test create AAAA record ep = &endpoint.Endpoint{ DNSName: "test.example.com", @@ -758,6 +846,16 @@ func TestCreateRecordV6(t *testing.T) { 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(context.Background(), ep); err != nil { + t.Fatal(err) + } + // Test create CNAME record ep = &endpoint.Endpoint{ DNSName: "source1.example.com", @@ -779,6 +877,16 @@ func TestCreateRecordV6(t *testing.T) { 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(context.Background(), ep); err == nil { + t.Fatal(err) + } + // Test create a wildcard record and ensure it fails ep = &endpoint.Endpoint{ DNSName: "*.example.com", diff --git a/provider/pihole/pihole.go b/provider/pihole/pihole.go index 53a7bf2af..cf401f374 100644 --- a/provider/pihole/pihole.go +++ b/provider/pihole/pihole.go @@ -19,6 +19,9 @@ package pihole import ( "context" "errors" + "slices" + + "github.com/google/go-cmp/cmp" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" @@ -32,7 +35,8 @@ var ErrNoPiholeServer = errors.New("no pihole server found in the environment or // PiholeProvider is an implementation of Provider for Pi-hole Local DNS. type PiholeProvider struct { provider.BaseProvider - api piholeAPI + api piholeAPI + apiVersion string } // PiholeConfig is used for configuring a PiholeProvider. @@ -70,7 +74,7 @@ func NewPiholeProvider(cfg PiholeConfig) (*PiholeProvider, error) { if err != nil { return nil, err } - return &PiholeProvider{api: api}, nil + return &PiholeProvider{api: api, apiVersion: cfg.APIVersion}, nil } // Records implements Provider, populating a slice of endpoints from @@ -105,6 +109,19 @@ func (p *PiholeProvider) ApplyChanges(ctx context.Context, changes *plan.Changes 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 } @@ -112,14 +129,23 @@ func (p *PiholeProvider) ApplyChanges(ctx context.Context, changes *plan.Changes // 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 { - // PiHole only has a single target; no need to compare other fields. - if newRecord.Targets[0] == ep.Targets[0] { - delete(updateNew, key) - continue + // 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 } - } - if err := p.api.deleteRecord(ctx, ep); err != nil { - return err } } diff --git a/provider/pihole/piholeV6_test.go b/provider/pihole/piholeV6_test.go index 22e0a2005..b14f77bc0 100644 --- a/provider/pihole/piholeV6_test.go +++ b/provider/pihole/piholeV6_test.go @@ -22,6 +22,7 @@ import ( "reflect" "testing" + "github.com/google/go-cmp/cmp" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) @@ -60,7 +61,7 @@ func (t *testPiholeClientV6) createRecord(_ context.Context, ep *endpoint.Endpoi 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 && existing.Targets[0] != ep.Targets[0] { + if existing.DNSName != ep.DNSName || cmp.Diff(existing.Targets, ep.Targets) != "" || existing.RecordType != ep.RecordType { newEPs = append(newEPs, existing) } } @@ -82,7 +83,8 @@ func (r *requestTrackerV6) clear() { func TestErrorHandling(t *testing.T) { requests := requestTrackerV6{} p := &PiholeProvider{ - api: &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, + api: &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, + apiVersion: "6", } p.api.(*testPiholeClientV6).trigger = "AERROR" @@ -121,7 +123,8 @@ func TestNewPiholeProviderV6(t *testing.T) { func TestProviderV6(t *testing.T) { requests := requestTrackerV6{} p := &PiholeProvider{ - api: &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, + api: &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests}, + apiVersion: "6", } records, err := p.Records(context.Background()) @@ -342,6 +345,11 @@ func TestProviderV6(t *testing.T) { Targets: []string{"10.0.0.1"}, RecordType: endpoint.RecordTypeA, }, + { + DNSName: "test2.example.com", + Targets: []string{"10.0.0.2"}, + RecordType: endpoint.RecordTypeA, + }, { DNSName: "test1.example.com", Targets: []string{"fc00::1:192:168:1:1"}, @@ -383,7 +391,7 @@ func TestProviderV6(t *testing.T) { expectedCreateA := endpoint.Endpoint{ DNSName: "test2.example.com", - Targets: []string{"10.0.0.1"}, + Targets: []string{"10.0.0.1", "10.0.0.2"}, RecordType: endpoint.RecordTypeA, } expectedDeleteA := endpoint.Endpoint{ diff --git a/provider/plural/plural.go b/provider/plural/plural.go index 691c7c683..bdb2e19c3 100644 --- a/provider/plural/plural.go +++ b/provider/plural/plural.go @@ -66,17 +66,17 @@ func NewPluralProvider(cluster, provider string) (*PluralProvider, error) { }, nil } -func (p *PluralProvider) Records(_ context.Context) (endpoints []*endpoint.Endpoint, err error) { +func (p *PluralProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { records, err := p.Client.DnsRecords() if err != nil { - return + return nil, err } - endpoints = make([]*endpoint.Endpoint, len(records)) + endpoints := make([]*endpoint.Endpoint, len(records)) for i, record := range records { endpoints[i] = endpoint.NewEndpoint(record.Name, record.Type, record.Records...) } - return + return endpoints, nil } func (p *PluralProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { diff --git a/provider/rfc2136/rfc2136.go b/provider/rfc2136/rfc2136.go index d9f7196cf..816b782ca 100644 --- a/provider/rfc2136/rfc2136.go +++ b/provider/rfc2136/rfc2136.go @@ -169,13 +169,13 @@ func NewRfc2136Provider(hosts []string, port int, zoneNames []string, insecure b } // KeyData will return TKEY name and TSIG handle to use for followon actions with a secure connection -func (r *rfc2136Provider) KeyData(nameserver string) (keyName string, handle *gss.Client, err error) { - handle, err = gss.NewClient(new(dns.Client)) +func (r *rfc2136Provider) KeyData(nameserver string) (string, *gss.Client, error) { + handle, err := gss.NewClient(new(dns.Client)) if err != nil { - return keyName, handle, err + return "", handle, err } - keyName, _, err = handle.NegotiateContextWithCredentials(nameserver, r.krb5Realm, r.krb5Username, r.krb5Password) + keyName, _, err := handle.NegotiateContextWithCredentials(nameserver, r.krb5Realm, r.krb5Username, r.krb5Password) if err != nil { return keyName, handle, err } @@ -247,7 +247,7 @@ OuterLoop: return eps, nil } -func (r *rfc2136Provider) IncomeTransfer(m *dns.Msg, nameserver string) (env chan *dns.Envelope, err error) { +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} @@ -408,9 +408,11 @@ func (r *rfc2136Provider) ApplyChanges(ctx context.Context, changes *plan.Change zone := findMsgZone(ep, r.zoneNames) m[zone].SetUpdate(zone) - r.UpdateRecord(m[zone], changes.UpdateOld[i], ep) + // 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[i].Targets[0], ep.DNSName) + r.RemoveReverseRecord(changes.UpdateOld[j].Targets[0], ep.DNSName) r.AddReverseRecord(ep.Targets[0], ep.DNSName) } } diff --git a/provider/rfc2136/rfc2136_test.go b/provider/rfc2136/rfc2136_test.go index 84178eea9..fc3d65c3f 100644 --- a/provider/rfc2136/rfc2136_test.go +++ b/provider/rfc2136/rfc2136_test.go @@ -149,7 +149,7 @@ func (r *rfc2136Stub) setOutput(output []string) error { return nil } -func (r *rfc2136Stub) IncomeTransfer(m *dns.Msg, a string) (env chan *dns.Envelope, err error) { +func (r *rfc2136Stub) IncomeTransfer(m *dns.Msg, a string) (chan *dns.Envelope, error) { outChan := make(chan *dns.Envelope) go func() { for _, e := range r.output { @@ -256,6 +256,17 @@ func createRfc2136StubProviderWithStrategy(stub *rfc2136Stub, strategy string) ( return NewRfc2136Provider([]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 NewRfc2136Provider([]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) @@ -959,3 +970,44 @@ func TestRandomLoadBalancing(t *testing.T) { 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(context.Background(), 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") +} diff --git a/provider/webhook/webhook.go b/provider/webhook/webhook.go index 7e5da1187..787ace17f 100644 --- a/provider/webhook/webhook.go +++ b/provider/webhook/webhook.go @@ -43,7 +43,6 @@ const ( var ( recordsErrorsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "webhook_provider", Name: "records_errors_total", Help: "Errors with Records method", @@ -51,7 +50,6 @@ var ( ) recordsRequestsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "webhook_provider", Name: "records_requests_total", Help: "Requests with Records method", @@ -59,7 +57,6 @@ var ( ) applyChangesErrorsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "webhook_provider", Name: "applychanges_errors_total", Help: "Errors with ApplyChanges method", @@ -67,7 +64,6 @@ var ( ) applyChangesRequestsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "webhook_provider", Name: "applychanges_requests_total", Help: "Requests with ApplyChanges method", @@ -75,7 +71,6 @@ var ( ) adjustEndpointsErrorsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "webhook_provider", Name: "adjustendpoints_errors_total", Help: "Errors with AdjustEndpoints method", @@ -83,7 +78,6 @@ var ( ) adjustEndpointsRequestsGauge = metrics.NewGaugeWithOpts( prometheus.GaugeOpts{ - Namespace: "external_dns", Subsystem: "webhook_provider", Name: "adjustendpoints_requests_total", Help: "Requests with AdjustEndpoints method", diff --git a/provider/zonefinder.go b/provider/zonefinder.go index a81f997b3..08e6b3162 100644 --- a/provider/zonefinder.go +++ b/provider/zonefinder.go @@ -41,21 +41,23 @@ func (z ZoneIDName) Add(zoneID, zoneName string) { // 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) (suitableZoneID, suitableZoneName string) { +func (z ZoneIDName) FindZone(hostname string) (string, string) { var name string - domain_labels := strings.Split(hostname, ".") - for i, label := range domain_labels { + domainLabels := strings.Split(hostname, ".") + for i, label := range domainLabels { if strings.Contains(label, "_") { continue } convertedLabel, err := idna.Lookup.ToUnicode(label) if err != nil { - log.Warnf("Failed to convert label '%s' of hostname '%s' to its Unicode form: %v", label, hostname, err) + log.Warnf("Failed to convert label %q of hostname %q to its Unicode form: %v", label, hostname, err) convertedLabel = label } - domain_labels[i] = convertedLabel + domainLabels[i] = convertedLabel } - name = strings.Join(domain_labels, ".") + name = strings.Join(domainLabels, ".") + + var suitableZoneID, suitableZoneName string for zoneID, zoneName := range z { if name == zoneName || strings.HasSuffix(name, "."+zoneName) { @@ -65,5 +67,5 @@ func (z ZoneIDName) FindZone(hostname string) (suitableZoneID, suitableZoneName } } } - return + return suitableZoneID, suitableZoneName } diff --git a/provider/zonefinder_test.go b/provider/zonefinder_test.go index b5afb047f..4eddd36cb 100644 --- a/provider/zonefinder_test.go +++ b/provider/zonefinder_test.go @@ -32,14 +32,16 @@ func TestZoneIDName(t *testing.T) { 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.エイミー.みんな") assert.Equal(t, ZoneIDName{ - "123456": "qux.baz", - "654321": "foo.qux.baz", - "987654": "エイミー.みんな", - "123123": "_metadata.example.com", - "456456": "_metadata.エイミー.みんな", + "123456": "qux.baz", + "654321": "foo.qux.baz", + "987654": "エイミー.みんな", + "123123": "_metadata.example.com", + "1231231": "_foo._metadata.example.com", + "456456": "_metadata.エイミー.みんな", }, z) // simple entry in a domain @@ -77,8 +79,12 @@ func TestZoneIDName(t *testing.T) { 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) + hook := testutils.LogsUnderTestWithLogLevel(log.WarnLevel, t) _, _ = z.FindZone("???") - testutils.TestHelperLogContains("Failed to convert label '???' of hostname '???' to its Unicode form: idna: disallowed rune U+003F", hook, t) + testutils.TestHelperLogContains("Failed to convert label \"???\" of hostname \"???\" to its Unicode form: idna: disallowed rune U+003F", hook, t) } diff --git a/registry/OWNERS b/registry/OWNERS new file mode 100644 index 000000000..4de969042 --- /dev/null +++ b/registry/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- registry diff --git a/registry/txt.go b/registry/txt.go index 2ce6ae36d..74152636a 100644 --- a/registry/txt.go +++ b/registry/txt.go @@ -399,7 +399,7 @@ func newaffixNameMapper(prefix, suffix, wildcardReplacement string) affixNameMap // extractRecordTypeDefaultPosition extracts record type from the default position // when not using '%{record_type}' in the prefix/suffix -func extractRecordTypeDefaultPosition(name string) (baseName, recordType string) { +func extractRecordTypeDefaultPosition(name string) (string, string) { nameS := strings.Split(name, "-") for _, t := range getSupportedTypes() { if nameS[0] == strings.ToLower(t) { @@ -411,7 +411,7 @@ func extractRecordTypeDefaultPosition(name string) (baseName, recordType string) // dropAffixExtractType strips TXT record to find an endpoint name it manages // it also returns the record type -func (pr affixNameMapper) dropAffixExtractType(name string) (baseName, recordType string) { +func (pr affixNameMapper) dropAffixExtractType(name string) (string, string) { prefix := pr.prefix suffix := pr.suffix @@ -458,7 +458,7 @@ func (pr affixNameMapper) isSuffix() bool { return len(pr.prefix) == 0 && len(pr.suffix) > 0 } -func (pr affixNameMapper) toEndpointName(txtDNSName string) (endpointName string, recordType string) { +func (pr affixNameMapper) toEndpointName(txtDNSName string) (string, string) { lowerDNSName := strings.ToLower(txtDNSName) // drop prefix diff --git a/registry/txt_test.go b/registry/txt_test.go index 7efa534c3..83356ad74 100644 --- a/registry/txt_test.go +++ b/registry/txt_test.go @@ -18,7 +18,6 @@ package registry import ( "context" - "fmt" "reflect" "strings" "testing" @@ -1511,7 +1510,6 @@ func TestNewTXTScheme(t *testing.T) { assert.Nil(t, ctx.Value(provider.RecordsContextKey)) } err := r.ApplyChanges(ctx, changes) - fmt.Println(err) require.NoError(t, err) } diff --git a/scripts/OWNERS b/scripts/OWNERS new file mode 100644 index 000000000..8f335dd0e --- /dev/null +++ b/scripts/OWNERS @@ -0,0 +1,4 @@ +# See the OWNERS docs at https://go.k8s.io/owners + +labels: +- scripts diff --git a/source/ambassador_host.go b/source/ambassador_host.go index 3de5d5bbe..6b1da8195 100644 --- a/source/ambassador_host.go +++ b/source/ambassador_host.go @@ -196,7 +196,7 @@ func (sc *ambassadorHostSource) endpointsFromHost(host *ambassador.Host, targets if host.Spec != nil { hostname := host.Spec.Hostname if hostname != "" { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } @@ -228,7 +228,7 @@ func (sc *ambassadorHostSource) targetsFromAmbassadorLoadBalancer(ctx context.Co // // Returns namespace, name, error. -func parseAmbLoadBalancerService(service string) (namespace, name string, err error) { +func parseAmbLoadBalancerService(service string) (string, string, error) { // Start by assuming that we have namespace/name. parts := strings.Split(service, "/") @@ -294,13 +294,7 @@ func newUnstructuredConverter() (*unstructuredConverter, error) { // Filter a list of Ambassador Host Resources to only return the ones that // contain the required External-DNS annotation filter func (sc *ambassadorHostSource) filterByAnnotations(ambassadorHosts []*ambassador.Host) ([]*ambassador.Host, error) { - // External-DNS Annotation Filter - labelSelector, err := metav1.ParseToLabelSelector(sc.annotationFilter) - if err != nil { - return nil, err - } - - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(sc.annotationFilter) if err != nil { return nil, err } diff --git a/source/annotations/annotations.go b/source/annotations/annotations.go index bd21ca2bc..abfce1359 100644 --- a/source/annotations/annotations.go +++ b/source/annotations/annotations.go @@ -18,37 +18,41 @@ import ( ) const ( + // AnnotationKeyPrefix is set on all annotations consumed by external-dns (outside of user templates) + // to provide easy filtering. + AnnotationKeyPrefix = "external-dns.alpha.kubernetes.io/" + // CloudflareProxiedKey The annotation used for determining if traffic will go through Cloudflare - CloudflareProxiedKey = "external-dns.alpha.kubernetes.io/cloudflare-proxied" - CloudflareCustomHostnameKey = "external-dns.alpha.kubernetes.io/cloudflare-custom-hostname" - CloudflareRegionKey = "external-dns.alpha.kubernetes.io/cloudflare-region-key" - CloudflareRecordCommentKey = "external-dns.alpha.kubernetes.io/cloudflare-record-comment" + CloudflareProxiedKey = AnnotationKeyPrefix + "cloudflare-proxied" + CloudflareCustomHostnameKey = AnnotationKeyPrefix + "cloudflare-custom-hostname" + CloudflareRegionKey = AnnotationKeyPrefix + "cloudflare-region-key" + CloudflareRecordCommentKey = AnnotationKeyPrefix + "cloudflare-record-comment" - AWSPrefix = "external-dns.alpha.kubernetes.io/aws-" - SCWPrefix = "external-dns.alpha.kubernetes.io/scw-" - WebhookPrefix = "external-dns.alpha.kubernetes.io/webhook-" - CloudflarePrefix = "external-dns.alpha.kubernetes.io/cloudflare-" + AWSPrefix = AnnotationKeyPrefix + "aws-" + SCWPrefix = AnnotationKeyPrefix + "scw-" + WebhookPrefix = AnnotationKeyPrefix + "webhook-" + CloudflarePrefix = AnnotationKeyPrefix + "cloudflare-" - TtlKey = "external-dns.alpha.kubernetes.io/ttl" + TtlKey = AnnotationKeyPrefix + "ttl" ttlMinimum = 1 ttlMaximum = math.MaxInt32 - SetIdentifierKey = "external-dns.alpha.kubernetes.io/set-identifier" - AliasKey = "external-dns.alpha.kubernetes.io/alias" - TargetKey = "external-dns.alpha.kubernetes.io/target" + SetIdentifierKey = AnnotationKeyPrefix + "set-identifier" + AliasKey = AnnotationKeyPrefix + "alias" + TargetKey = AnnotationKeyPrefix + "target" // The annotation used for figuring out which controller is responsible - ControllerKey = "external-dns.alpha.kubernetes.io/controller" + ControllerKey = AnnotationKeyPrefix + "controller" // The annotation used for defining the desired hostname - HostnameKey = "external-dns.alpha.kubernetes.io/hostname" + HostnameKey = AnnotationKeyPrefix + "hostname" // The annotation used for specifying whether the public or private interface address is used - AccessKey = "external-dns.alpha.kubernetes.io/access" + AccessKey = AnnotationKeyPrefix + "access" // The annotation used for specifying the type of endpoints to use for headless services - EndpointsTypeKey = "external-dns.alpha.kubernetes.io/endpoints-type" + EndpointsTypeKey = AnnotationKeyPrefix + "endpoints-type" // 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 = "external-dns.alpha.kubernetes.io/ingress-hostname-source" + IngressHostnameSourceKey = AnnotationKeyPrefix + "ingress-hostname-source" // The value of the controller annotation so that we feel responsible ControllerValue = "dns-controller" // The annotation used for defining the desired hostname - InternalHostnameKey = "external-dns.alpha.kubernetes.io/internal-hostname" + InternalHostnameKey = AnnotationKeyPrefix + "internal-hostname" ) diff --git a/source/annotations/processors_test.go b/source/annotations/processors_test.go index d423edc71..c056f52a3 100644 --- a/source/annotations/processors_test.go +++ b/source/annotations/processors_test.go @@ -47,6 +47,12 @@ func TestParseAnnotationFilter(t *testing.T) { expectedSelector: labels.Set{}.AsSelector(), expectError: false, }, + { + name: "wrong annotation filter", + annotationFilter: "=test", + expectedSelector: nil, + expectError: true, + }, } for _, tt := range tests { diff --git a/source/contour_httpproxy.go b/source/contour_httpproxy.go index b0a57171c..f530ee008 100644 --- a/source/contour_httpproxy.go +++ b/source/contour_httpproxy.go @@ -25,7 +25,6 @@ import ( projectcontour "github.com/projectcontour/contour/apis/projectcontour/v1" 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/client-go/dynamic" @@ -205,18 +204,14 @@ func (sc *httpProxySource) endpointsFromTemplate(httpProxy *projectcontour.HTTPP var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } // filterByAnnotations filters a list of configs by a given annotation selector. func (sc *httpProxySource) filterByAnnotations(httpProxies []*projectcontour.HTTPProxy) ([]*projectcontour.HTTPProxy, error) { - labelSelector, err := metav1.ParseToLabelSelector(sc.annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(sc.annotationFilter) if err != nil { return nil, err } @@ -263,7 +258,7 @@ func (sc *httpProxySource) endpointsFromHTTPProxy(httpProxy *projectcontour.HTTP if virtualHost := httpProxy.Spec.VirtualHost; virtualHost != nil { if fqdn := virtualHost.Fqdn; fqdn != "" { - endpoints = append(endpoints, endpointsForHostname(fqdn, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(fqdn, targets, ttl, providerSpecific, setIdentifier, resource)...) } } @@ -271,7 +266,7 @@ func (sc *httpProxySource) endpointsFromHTTPProxy(httpProxy *projectcontour.HTTP if !sc.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(httpProxy.Annotations) for _, hostname := range hostnameList { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } diff --git a/source/crd.go b/source/crd.go index a0c54e1e0..2911f1f6d 100644 --- a/source/crd.go +++ b/source/crd.go @@ -51,7 +51,7 @@ type crdSource struct { codec runtime.ParameterCodec annotationFilter string labelSelector labels.Selector - informer *cache.SharedInformer + informer cache.SharedInformer } func addKnownTypes(scheme *runtime.Scheme, groupVersion schema.GroupVersion) error { @@ -123,9 +123,9 @@ func NewCRDSource(crdClient rest.Interface, namespace, kind string, annotationFi if startInformer { // 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. - informer := cache.NewSharedInformer( + sourceCrd.informer = cache.NewSharedInformer( &cache.ListWatch{ - ListWithContextFunc: func(ctx context.Context, lo metav1.ListOptions) (result runtime.Object, err error) { + 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) { @@ -134,8 +134,7 @@ func NewCRDSource(crdClient rest.Interface, namespace, kind string, annotationFi }, &apiv1alpha1.DNSEndpoint{}, 0) - sourceCrd.informer = &informer - go informer.Run(wait.NeverStop) + go sourceCrd.informer.Run(wait.NeverStop) } return &sourceCrd, nil } @@ -145,8 +144,7 @@ func (cs *crdSource) AddEventHandler(_ context.Context, handler func()) { 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 - informer := *cs.informer - _, _ = informer.AddEventHandler( + _, _ = cs.informer.AddEventHandler( cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { handler() @@ -190,11 +188,9 @@ func (cs *crdSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error illegalTarget := false for _, target := range ep.Targets { - if ep.RecordType != endpoint.RecordTypeNAPTR && strings.HasSuffix(target, ".") { - illegalTarget = true - break - } - if ep.RecordType == endpoint.RecordTypeNAPTR && !strings.HasSuffix(target, ".") { + isNAPTR := ep.RecordType == endpoint.RecordTypeNAPTR + hasDot := strings.HasSuffix(target, ".") + if (isNAPTR && !hasDot) || (!isNAPTR && hasDot) { illegalTarget = true break } @@ -235,20 +231,19 @@ func (cs *crdSource) watch(ctx context.Context, opts *metav1.ListOptions) (watch Watch(ctx) } -func (cs *crdSource) List(ctx context.Context, opts *metav1.ListOptions) (result *apiv1alpha1.DNSEndpointList, err error) { - result = &apiv1alpha1.DNSEndpointList{} - err = cs.crdClient.Get(). +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) - return } -func (cs *crdSource) UpdateStatus(ctx context.Context, dnsEndpoint *apiv1alpha1.DNSEndpoint) (result *apiv1alpha1.DNSEndpoint, err error) { - result = &apiv1alpha1.DNSEndpoint{} - err = cs.crdClient.Put(). +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). @@ -256,7 +251,6 @@ func (cs *crdSource) UpdateStatus(ctx context.Context, dnsEndpoint *apiv1alpha1. Body(dnsEndpoint). Do(ctx). Into(result) - return } // filterByAnnotations filters a list of dnsendpoints by a given annotation selector. diff --git a/source/crd_test.go b/source/crd_test.go index 6214c74b3..0f92d0db3 100644 --- a/source/crd_test.go +++ b/source/crd_test.go @@ -736,7 +736,7 @@ func helperCreateWatcherWithInformer(t *testing.T) (*cachetesting.FakeController }, time.Second, 10*time.Millisecond) cs := &crdSource{ - informer: &informer, + informer: informer, } return watcher, *cs diff --git a/source/endpoints.go b/source/endpoints.go index ded87c4f2..b3667052d 100644 --- a/source/endpoints.go +++ b/source/endpoints.go @@ -22,8 +22,8 @@ import ( "sigs.k8s.io/external-dns/endpoint" ) -// endpointsForHostname returns the endpoint objects for each host-target combination. -func endpointsForHostname(hostname string, targets endpoint.Targets, ttl endpoint.TTL, providerSpecific endpoint.ProviderSpecific, setIdentifier string, resource string) []*endpoint.Endpoint { +// EndpointsForHostname returns the endpoint objects for each host-target combination. +func EndpointsForHostname(hostname string, targets endpoint.Targets, ttl endpoint.TTL, providerSpecific endpoint.ProviderSpecific, setIdentifier string, resource string) []*endpoint.Endpoint { var ( endpoints []*endpoint.Endpoint aTargets endpoint.Targets diff --git a/source/endpoints_test.go b/source/endpoints_test.go index dcd3571d7..9470db689 100644 --- a/source/endpoints_test.go +++ b/source/endpoints_test.go @@ -117,7 +117,7 @@ func TestEndpointsForHostname(t *testing.T) { 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) + result := EndpointsForHostname(tt.hostname, tt.targets, tt.ttl, tt.providerSpecific, tt.setIdentifier, tt.resource) assert.Equal(t, tt.expected, result) }) } diff --git a/source/f5_transportserver.go b/source/f5_transportserver.go index 6b08f6288..8061bd8b3 100644 --- a/source/f5_transportserver.go +++ b/source/f5_transportserver.go @@ -23,7 +23,6 @@ import ( "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" @@ -163,7 +162,7 @@ func (ts *f5TransportServerSource) endpointsFromTransportServers(transportServer targets = append(targets, transportServer.Status.VSAddress) } - endpoints = append(endpoints, endpointsForHostname(transportServer.Spec.Host, targets, ttl, nil, "", resource)...) + endpoints = append(endpoints, EndpointsForHostname(transportServer.Spec.Host, targets, ttl, nil, "", resource)...) } return endpoints, nil @@ -186,12 +185,7 @@ func newTSUnstructuredConverter() (*unstructuredConverter, error) { // filterByAnnotations filters a list of TransportServers by a given annotation selector. func (ts *f5TransportServerSource) filterByAnnotations(transportServers []*f5.TransportServer) ([]*f5.TransportServer, error) { - labelSelector, err := metav1.ParseToLabelSelector(ts.annotationFilter) - if err != nil { - return nil, err - } - - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(ts.annotationFilter) if err != nil { return nil, err } diff --git a/source/f5_virtualserver.go b/source/f5_virtualserver.go index 7d28f6e7b..a97baa9e0 100644 --- a/source/f5_virtualserver.go +++ b/source/f5_virtualserver.go @@ -24,7 +24,6 @@ import ( "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" @@ -169,7 +168,7 @@ func (vs *f5VirtualServerSource) endpointsFromVirtualServers(virtualServers []*f targets = append(targets, virtualServer.Status.VSAddress) } - endpoints = append(endpoints, endpointsForHostname(virtualServer.Spec.Host, targets, ttl, nil, "", resource)...) + endpoints = append(endpoints, EndpointsForHostname(virtualServer.Spec.Host, targets, ttl, nil, "", resource)...) } return endpoints, nil @@ -192,12 +191,7 @@ func newVSUnstructuredConverter() (*unstructuredConverter, error) { // filterByAnnotations filters a list of VirtualServers by a given annotation selector. func (vs *f5VirtualServerSource) filterByAnnotations(virtualServers []*f5.VirtualServer) ([]*f5.VirtualServer, error) { - labelSelector, err := metav1.ParseToLabelSelector(vs.annotationFilter) - if err != nil { - return nil, err - } - - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(vs.annotationFilter) if err != nil { return nil, err } diff --git a/source/fqdn/fqdn.go b/source/fqdn/fqdn.go index 1c01d0450..17e91d80a 100644 --- a/source/fqdn/fqdn.go +++ b/source/fqdn/fqdn.go @@ -28,7 +28,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) -func ParseTemplate(input string) (tmpl *template.Template, err error) { +func ParseTemplate(input string) (*template.Template, error) { if input == "" { return nil, nil } diff --git a/source/gateway.go b/source/gateway.go index 811bf96de..445352838 100644 --- a/source/gateway.go +++ b/source/gateway.go @@ -242,7 +242,7 @@ func (src *gatewayRouteSource) Endpoints(ctx context.Context) ([]*endpoint.Endpo providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(annots) ttl := annotations.TTLFromAnnotations(annots, resource) for host, targets := range hostTargets { - routeEndpoints = append(routeEndpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) + routeEndpoints = append(routeEndpoints, EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } log.Debugf("Endpoints generated from %s %s/%s: %v", src.rtKind, meta.Namespace, meta.Name, routeEndpoints) diff --git a/source/gloo_proxy.go b/source/gloo_proxy.go index 5c3e957a6..63eafecf0 100644 --- a/source/gloo_proxy.go +++ b/source/gloo_proxy.go @@ -169,7 +169,7 @@ func (gs *glooSource) generateEndpointsFromProxy(ctx context.Context, proxy *pro ttl := annotations.TTLFromAnnotations(ants, resource) providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ants) for _, domain := range virtualHost.Domains { - endpoints = append(endpoints, endpointsForHostname(strings.TrimSuffix(domain, "."), targets, ttl, providerSpecific, setIdentifier, "")...) + endpoints = append(endpoints, EndpointsForHostname(strings.TrimSuffix(domain, "."), targets, ttl, providerSpecific, setIdentifier, "")...) } } } diff --git a/source/informers/fake.go b/source/informers/fake.go new file mode 100644 index 000000000..aed7b3aee --- /dev/null +++ b/source/informers/fake.go @@ -0,0 +1,60 @@ +/* +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" + 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()) +} diff --git a/source/informers/handlers.go b/source/informers/handlers.go new file mode 100644 index 000000000..2d2067f45 --- /dev/null +++ b/source/informers/handlers.go @@ -0,0 +1,38 @@ +/* +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() + } + } + }, + } +} diff --git a/source/informers/handlers_test.go b/source/informers/handlers_test.go new file mode 100644 index 000000000..519b99b52 --- /dev/null +++ b/source/informers/handlers_test.go @@ -0,0 +1,50 @@ +/* +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) + } + }) + } +} diff --git a/source/informers/indexers.go b/source/informers/indexers.go new file mode 100644 index 000000000..b2b947da4 --- /dev/null +++ b/source/informers/indexers.go @@ -0,0 +1,113 @@ +/* +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 interface{}) ([]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 +} diff --git a/source/informers/indexers_test.go b/source/informers/indexers_test.go new file mode 100644 index 000000000..d9b5de61e --- /dev/null +++ b/source/informers/indexers_test.go @@ -0,0 +1,185 @@ +/* +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) +} diff --git a/source/ingress.go b/source/ingress.go index d3b8f8d09..e0735eba0 100644 --- a/source/ingress.go +++ b/source/ingress.go @@ -204,7 +204,7 @@ func (sc *ingressSource) endpointsFromTemplate(ing *networkv1.Ingress) ([]*endpo var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } @@ -299,7 +299,7 @@ func endpointsFromIngress(ing *networkv1.Ingress, ignoreHostnameAnnotation bool, if rule.Host == "" { continue } - definedHostsEndpoints = append(definedHostsEndpoints, endpointsForHostname(rule.Host, targets, ttl, providerSpecific, setIdentifier, resource)...) + definedHostsEndpoints = append(definedHostsEndpoints, EndpointsForHostname(rule.Host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } @@ -310,7 +310,7 @@ func endpointsFromIngress(ing *networkv1.Ingress, ignoreHostnameAnnotation bool, if host == "" { continue } - definedHostsEndpoints = append(definedHostsEndpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) + definedHostsEndpoints = append(definedHostsEndpoints, EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } } @@ -319,7 +319,7 @@ func endpointsFromIngress(ing *networkv1.Ingress, ignoreHostnameAnnotation bool, var annotationEndpoints []*endpoint.Endpoint if !ignoreHostnameAnnotation { for _, hostname := range annotations.HostnamesFromAnnotations(ing.Annotations) { - annotationEndpoints = append(annotationEndpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + annotationEndpoints = append(annotationEndpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } diff --git a/source/istio_gateway.go b/source/istio_gateway.go index 39d96d6b3..9043cdcfe 100644 --- a/source/istio_gateway.go +++ b/source/istio_gateway.go @@ -24,10 +24,10 @@ import ( "text/template" log "github.com/sirupsen/logrus" - networkingv1alpha3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + 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" - networkingv1alpha3informer "istio.io/client-go/pkg/informers/externalversions/networking/v1alpha3" + networkingv1beta1informer "istio.io/client-go/pkg/informers/externalversions/networking/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" kubeinformers "k8s.io/client-go/informers" @@ -57,7 +57,7 @@ type gatewaySource struct { combineFQDNAnnotation bool ignoreHostnameAnnotation bool serviceInformer coreinformers.ServiceInformer - gatewayInformer networkingv1alpha3informer.GatewayInformer + gatewayInformer networkingv1beta1informer.GatewayInformer } // NewIstioGatewaySource creates a new gatewaySource with the given config. @@ -81,10 +81,10 @@ func NewIstioGatewaySource( informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace)) serviceInformer := informerFactory.Core().V1().Services() istioInformerFactory := istioinformers.NewSharedInformerFactory(istioClient, 0) - gatewayInformer := istioInformerFactory.Networking().V1alpha3().Gateways() + gatewayInformer := istioInformerFactory.Networking().V1beta1().Gateways() // Add default resource event handlers to properly initialize informer. - serviceInformer.Informer().AddEventHandler( + _, _ = serviceInformer.Informer().AddEventHandler( cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { log.Debug("service added") @@ -92,7 +92,7 @@ func NewIstioGatewaySource( }, ) - gatewayInformer.Informer().AddEventHandler( + _, _ = gatewayInformer.Informer().AddEventHandler( cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { log.Debug("gateway added") @@ -127,7 +127,7 @@ func NewIstioGatewaySource( // 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.NetworkingV1alpha3().Gateways(sc.namespace).List(ctx, metav1.ListOptions{}) + gwList, err := sc.istioClient.NetworkingV1beta1().Gateways(sc.namespace).List(ctx, metav1.ListOptions{}) if err != nil { return nil, err } @@ -140,12 +140,14 @@ func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e var endpoints []*endpoint.Endpoint + log.Debugf("Found %d gateways in namespace %s", len(gateways), sc.namespace) + for _, gateway := range gateways { // Check controller annotation to see if we are responsible. controller, ok := gateway.Annotations[controllerAnnotationKey] if ok && controller != controllerAnnotationValue { - log.Debugf("Skipping gateway %s/%s because controller value does not match, found: %s, required: %s", - gateway.Namespace, gateway.Name, controller, controllerAnnotationValue) + log.Debugf("Skipping gateway %s/%s,%s because controller value does not match, found: %s, required: %s", + gateway.Namespace, gateway.APIVersion, gateway.Name, controller, controllerAnnotationValue) continue } @@ -168,6 +170,8 @@ func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e } } + log.Debugf("Processing gateway '%s/%s.%s' and hosts %q", gateway.Namespace, gateway.APIVersion, gateway.Name, strings.Join(gwHostnames, ",")) + if len(gwHostnames) == 0 { log.Debugf("No hostnames could be generated from gateway %s/%s", gateway.Namespace, gateway.Name) continue @@ -183,10 +187,11 @@ func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e continue } - log.Debugf("Endpoints generated from gateway: %s/%s: %v", gateway.Namespace, gateway.Name, gwEndpoints) + log.Debugf("Endpoints generated from %q '%s/%s.%s': %q", gateway.Kind, gateway.Namespace, gateway.APIVersion, gateway.Name, gwEndpoints) endpoints = append(endpoints, gwEndpoints...) } + // TODO: sort on endpoint creation for _, ep := range endpoints { sort.Sort(ep.Targets) } @@ -198,11 +203,11 @@ func (sc *gatewaySource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, e func (sc *gatewaySource) AddEventHandler(ctx context.Context, handler func()) { log.Debug("Adding event handler for Istio Gateway") - sc.gatewayInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) + _, _ = sc.gatewayInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } // filterByAnnotations filters a list of configs by a given annotation selector. -func (sc *gatewaySource) filterByAnnotations(gateways []*networkingv1alpha3.Gateway) ([]*networkingv1alpha3.Gateway, error) { +func (sc *gatewaySource) filterByAnnotations(gateways []*networkingv1beta1.Gateway) ([]*networkingv1beta1.Gateway, error) { selector, err := annotations.ParseFilter(sc.annotationFilter) if err != nil { return nil, err @@ -213,7 +218,7 @@ func (sc *gatewaySource) filterByAnnotations(gateways []*networkingv1alpha3.Gate return gateways, nil } - var filteredList []*networkingv1alpha3.Gateway + var filteredList []*networkingv1beta1.Gateway for _, gw := range gateways { // include if the annotations match the selector @@ -225,7 +230,7 @@ func (sc *gatewaySource) filterByAnnotations(gateways []*networkingv1alpha3.Gate return filteredList, nil } -func (sc *gatewaySource) targetsFromIngress(ctx context.Context, ingressStr string, gateway *networkingv1alpha3.Gateway) (targets endpoint.Targets, err error) { +func (sc *gatewaySource) targetsFromIngress(ctx context.Context, 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) @@ -234,10 +239,12 @@ func (sc *gatewaySource) targetsFromIngress(ctx context.Context, ingressStr stri namespace = gateway.Namespace } + targets := make(endpoint.Targets, 0) + ingress, err := sc.kubeClient.NetworkingV1().Ingresses(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { log.Error(err) - return + return nil, err } for _, lb := range ingress.Status.LoadBalancer.Ingress { if lb.IP != "" { @@ -246,10 +253,10 @@ func (sc *gatewaySource) targetsFromIngress(ctx context.Context, ingressStr stri targets = append(targets, lb.Hostname) } } - return + return targets, nil } -func (sc *gatewaySource) targetsFromGateway(ctx context.Context, gateway *networkingv1alpha3.Gateway) (endpoint.Targets, error) { +func (sc *gatewaySource) targetsFromGateway(ctx context.Context, gateway *networkingv1beta1.Gateway) (endpoint.Targets, error) { targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) > 0 { return targets, nil @@ -264,32 +271,31 @@ func (sc *gatewaySource) targetsFromGateway(ctx context.Context, gateway *networ } // endpointsFromGatewayConfig extracts the endpoints from an Istio Gateway Config object -func (sc *gatewaySource) endpointsFromGateway(ctx context.Context, hostnames []string, gateway *networkingv1alpha3.Gateway) ([]*endpoint.Endpoint, error) { +func (sc *gatewaySource) endpointsFromGateway(ctx context.Context, hostnames []string, gateway *networkingv1beta1.Gateway) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint var err error - resource := fmt.Sprintf("gateway/%s/%s", gateway.Namespace, gateway.Name) - - ttl := annotations.TTLFromAnnotations(gateway.Annotations, resource) - - targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) - if len(targets) == 0 { - targets, err = sc.targetsFromGateway(ctx, gateway) - if err != nil { - return nil, err - } + targets, err := sc.targetsFromGateway(ctx, 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, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } -func (sc *gatewaySource) hostNamesFromGateway(gateway *networkingv1alpha3.Gateway) ([]string, error) { +func (sc *gatewaySource) hostNamesFromGateway(gateway *networkingv1beta1.Gateway) ([]string, error) { var hostnames []string for _, server := range gateway.Spec.Servers { for _, host := range server.Hosts { diff --git a/source/istio_gateway_test.go b/source/istio_gateway_test.go index 50f6e4059..21f57009e 100644 --- a/source/istio_gateway_test.go +++ b/source/istio_gateway_test.go @@ -24,8 +24,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - networkingv1alpha3api "istio.io/api/networking/v1alpha3" - networkingv1alpha3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + networkingv1alpha3api "istio.io/api/networking/v1beta1" + networkingv1alpha3 "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" @@ -1494,7 +1494,7 @@ func testGatewayEndpoints(t *testing.T) { fakeIstioClient := istiofake.NewSimpleClientset() for _, config := range ti.configItems { gatewayCfg := config.Config() - _, err := fakeIstioClient.NetworkingV1alpha3().Gateways(ti.targetNamespace).Create(context.Background(), gatewayCfg, metav1.CreateOptions{}) + _, err := fakeIstioClient.NetworkingV1beta1().Gateways(ti.targetNamespace).Create(context.Background(), gatewayCfg, metav1.CreateOptions{}) require.NoError(t, err) } diff --git a/source/istio_virtualservice.go b/source/istio_virtualservice.go index cd106b2f3..d16c236b0 100644 --- a/source/istio_virtualservice.go +++ b/source/istio_virtualservice.go @@ -20,15 +20,16 @@ import ( "cmp" "context" "fmt" + "slices" "sort" "strings" "text/template" log "github.com/sirupsen/logrus" - networkingv1alpha3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + 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" - networkingv1alpha3informer "istio.io/client-go/pkg/informers/externalversions/networking/v1alpha3" + networkingv1beta1informer "istio.io/client-go/pkg/informers/externalversions/networking/v1beta1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -58,8 +59,8 @@ type virtualServiceSource struct { combineFQDNAnnotation bool ignoreHostnameAnnotation bool serviceInformer coreinformers.ServiceInformer - virtualserviceInformer networkingv1alpha3informer.VirtualServiceInformer - gatewayInformer networkingv1alpha3informer.GatewayInformer + vServiceInformer networkingv1beta1informer.VirtualServiceInformer + gatewayInformer networkingv1beta1informer.GatewayInformer } // NewIstioVirtualServiceSource creates a new virtualServiceSource with the given config. @@ -83,11 +84,11 @@ func NewIstioVirtualServiceSource( informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace)) serviceInformer := informerFactory.Core().V1().Services() istioInformerFactory := istioinformers.NewSharedInformerFactoryWithOptions(istioClient, 0, istioinformers.WithNamespace(namespace)) - virtualServiceInformer := istioInformerFactory.Networking().V1alpha3().VirtualServices() - gatewayInformer := istioInformerFactory.Networking().V1alpha3().Gateways() + virtualServiceInformer := istioInformerFactory.Networking().V1beta1().VirtualServices() + gatewayInformer := istioInformerFactory.Networking().V1beta1().Gateways() // Add default resource event handlers to properly initialize informer. - serviceInformer.Informer().AddEventHandler( + _, _ = serviceInformer.Informer().AddEventHandler( cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { log.Debug("service added") @@ -95,7 +96,7 @@ func NewIstioVirtualServiceSource( }, ) - virtualServiceInformer.Informer().AddEventHandler( + _, _ = virtualServiceInformer.Informer().AddEventHandler( cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { log.Debug("virtual service added") @@ -103,7 +104,7 @@ func NewIstioVirtualServiceSource( }, ) - gatewayInformer.Informer().AddEventHandler( + _, _ = gatewayInformer.Informer().AddEventHandler( cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { log.Debug("gateway added") @@ -131,7 +132,7 @@ func NewIstioVirtualServiceSource( combineFQDNAnnotation: combineFQDNAnnotation, ignoreHostnameAnnotation: ignoreHostnameAnnotation, serviceInformer: serviceInformer, - virtualserviceInformer: virtualServiceInformer, + vServiceInformer: virtualServiceInformer, gatewayInformer: gatewayInformer, }, nil } @@ -139,7 +140,7 @@ func NewIstioVirtualServiceSource( // 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.virtualserviceInformer.Lister().VirtualServices(sc.namespace).List(labels.Everything()) + virtualServices, err := sc.vServiceInformer.Lister().VirtualServices(sc.namespace).List(labels.Everything()) if err != nil { return nil, err } @@ -150,23 +151,25 @@ func (sc *virtualServiceSource) Endpoints(ctx context.Context) ([]*endpoint.Endp var endpoints []*endpoint.Endpoint - for _, virtualService := range virtualServices { + log.Debugf("Found %d virtualservice in namespace %s", len(virtualServices), sc.namespace) + + for _, vService := range virtualServices { // Check controller annotation to see if we are responsible. - controller, ok := virtualService.Annotations[controllerAnnotationKey] + controller, ok := vService.Annotations[controllerAnnotationKey] if ok && controller != controllerAnnotationValue { - log.Debugf("Skipping VirtualService %s/%s because controller value does not match, found: %s, required: %s", - virtualService.Namespace, virtualService.Name, controller, controllerAnnotationValue) + log.Debugf("Skipping VirtualService %s/%s.%s because controller value does not match, found: %s, required: %s", + vService.Namespace, vService.APIVersion, vService.Name, controller, controllerAnnotationValue) continue } - gwEndpoints, err := sc.endpointsFromVirtualService(ctx, virtualService) + gwEndpoints, err := sc.endpointsFromVirtualService(ctx, vService) if err != nil { return nil, err } // apply template if host is missing on VirtualService if (sc.combineFQDNAnnotation || len(gwEndpoints) == 0) && sc.fqdnTemplate != nil { - iEndpoints, err := sc.endpointsFromTemplate(ctx, virtualService) + iEndpoints, err := sc.endpointsFromTemplate(ctx, vService) if err != nil { return nil, err } @@ -179,14 +182,15 @@ func (sc *virtualServiceSource) Endpoints(ctx context.Context) ([]*endpoint.Endp } if len(gwEndpoints) == 0 { - log.Debugf("No endpoints could be generated from VirtualService %s/%s", virtualService.Namespace, virtualService.Name) + log.Debugf("No endpoints could be generated from VirtualService %s/%s", vService.Namespace, vService.Name) continue } - log.Debugf("Endpoints generated from VirtualService: %s/%s: %v", virtualService.Namespace, virtualService.Name, gwEndpoints) + log.Debugf("Endpoints generated from %q '%s/%s.%s': %q", vService.Kind, vService.Namespace, vService.APIVersion, vService.Name, gwEndpoints) endpoints = append(endpoints, gwEndpoints...) } + // TODO: sort on endpoint creation for _, ep := range endpoints { sort.Sort(ep.Targets) } @@ -198,16 +202,16 @@ func (sc *virtualServiceSource) Endpoints(ctx context.Context) ([]*endpoint.Endp func (sc *virtualServiceSource) AddEventHandler(_ context.Context, handler func()) { log.Debug("Adding event handler for Istio VirtualService") - sc.virtualserviceInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) + _, _ = sc.vServiceInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } -func (sc *virtualServiceSource) getGateway(_ context.Context, gatewayStr string, virtualService *networkingv1alpha3.VirtualService) (*networkingv1alpha3.Gateway, error) { +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 := parseGateway(gatewayStr) + 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 @@ -229,7 +233,7 @@ func (sc *virtualServiceSource) getGateway(_ context.Context, gatewayStr string, return gateway, nil } -func (sc *virtualServiceSource) endpointsFromTemplate(ctx context.Context, virtualService *networkingv1alpha3.VirtualService) ([]*endpoint.Endpoint, error) { +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 @@ -247,13 +251,13 @@ func (sc *virtualServiceSource) endpointsFromTemplate(ctx context.Context, virtu if err != nil { return endpoints, err } - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } // filterByAnnotations filters a list of configs by a given annotation selector. -func (sc *virtualServiceSource) filterByAnnotations(virtualservices []*networkingv1alpha3.VirtualService) ([]*networkingv1alpha3.VirtualService, error) { +func (sc *virtualServiceSource) filterByAnnotations(vServices []*v1beta1.VirtualService) ([]*v1beta1.VirtualService, error) { selector, err := annotations.ParseFilter(sc.annotationFilter) if err != nil { return nil, err @@ -261,12 +265,12 @@ func (sc *virtualServiceSource) filterByAnnotations(virtualservices []*networkin // empty filter returns original list if selector.Empty() { - return virtualservices, nil + return vServices, nil } - var filteredList []*networkingv1alpha3.VirtualService + var filteredList []*v1beta1.VirtualService - for _, vs := range virtualservices { + for _, vs := range vServices { // include if the annotations match the selector if selector.Matches(labels.Set(vs.Annotations)) { filteredList = append(filteredList, vs) @@ -278,26 +282,24 @@ func (sc *virtualServiceSource) filterByAnnotations(virtualservices []*networkin // append a target to the list of targets unless it's already in the list func appendUnique(targets []string, target string) []string { - for _, element := range targets { - if element == target { - return targets - } + if slices.Contains(targets, target) { + return targets } return append(targets, target) } -func (sc *virtualServiceSource) targetsFromVirtualService(ctx context.Context, virtualService *networkingv1alpha3.VirtualService, vsHost string) ([]string, error) { +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 virtualService.Spec.Gateways { - gw, err := sc.getGateway(ctx, gateway, virtualService) + 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(virtualService, gw, vsHost) { + if !virtualServiceBindsToGateway(vService, gw, vsHost) { continue } tgs, err := sc.targetsFromGateway(ctx, gw) @@ -308,24 +310,23 @@ func (sc *virtualServiceSource) targetsFromVirtualService(ctx context.Context, v targets = appendUnique(targets, target) } } - return targets, nil } // endpointsFromVirtualService extracts the endpoints from an Istio VirtualService Config object -func (sc *virtualServiceSource) endpointsFromVirtualService(ctx context.Context, virtualservice *networkingv1alpha3.VirtualService) ([]*endpoint.Endpoint, error) { +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", virtualservice.Namespace, virtualservice.Name) + resource := fmt.Sprintf("virtualservice/%s/%s", vService.Namespace, vService.Name) - ttl := annotations.TTLFromAnnotations(virtualservice.Annotations, resource) + ttl := annotations.TTLFromAnnotations(vService.Annotations, resource) - targetsFromAnnotation := annotations.TargetsFromTargetAnnotation(virtualservice.Annotations) + targetsFromAnnotation := annotations.TargetsFromTargetAnnotation(vService.Annotations) - providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(virtualservice.Annotations) + providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(vService.Annotations) - for _, host := range virtualservice.Spec.Hosts { + for _, host := range vService.Spec.Hosts { if host == "" || host == "*" { continue } @@ -340,27 +341,27 @@ func (sc *virtualServiceSource) endpointsFromVirtualService(ctx context.Context, targets := targetsFromAnnotation if len(targets) == 0 { - targets, err = sc.targetsFromVirtualService(ctx, virtualservice, host) + targets, err = sc.targetsFromVirtualService(ctx, vService, host) if err != nil { return endpoints, err } } - endpoints = append(endpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } // Skip endpoints if we do not want entries from annotations if !sc.ignoreHostnameAnnotation { - hostnameList := annotations.HostnamesFromAnnotations(virtualservice.Annotations) + hostnameList := annotations.HostnamesFromAnnotations(vService.Annotations) for _, hostname := range hostnameList { targets := targetsFromAnnotation if len(targets) == 0 { - targets, err = sc.targetsFromVirtualService(ctx, virtualservice, hostname) + targets, err = sc.targetsFromVirtualService(ctx, vService, hostname) if err != nil { return endpoints, err } } - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } @@ -369,13 +370,13 @@ func (sc *virtualServiceSource) endpointsFromVirtualService(ctx context.Context, // 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(virtualService *networkingv1alpha3.VirtualService, gateway *networkingv1alpha3.Gateway, vsHost string) bool { +func virtualServiceBindsToGateway(vService *v1beta1.VirtualService, gateway *v1beta1.Gateway, vsHost string) bool { isValid := false - if len(virtualService.Spec.ExportTo) == 0 { + if len(vService.Spec.ExportTo) == 0 { isValid = true } else { - for _, ns := range virtualService.Spec.ExportTo { - if ns == "*" || ns == gateway.Namespace || (ns == "." && gateway.Namespace == virtualService.Namespace) { + for _, ns := range vService.Spec.ExportTo { + if ns == "*" || ns == gateway.Namespace || (ns == "." && gateway.Namespace == vService.Namespace) { isValid = true } } @@ -396,7 +397,7 @@ func virtualServiceBindsToGateway(virtualService *networkingv1alpha3.VirtualServ continue } - if namespace == "*" || namespace == virtualService.Namespace || (namespace == "." && virtualService.Namespace == gateway.Namespace) { + if namespace == "*" || namespace == vService.Namespace || (namespace == "." && vService.Namespace == gateway.Namespace) { if host == "*" { return true } @@ -416,20 +417,7 @@ func virtualServiceBindsToGateway(virtualService *networkingv1alpha3.VirtualServ return false } -func parseGateway(gateway string) (namespace, name string, err error) { - parts := strings.Split(gateway, "/") - if len(parts) == 2 { - namespace, name = parts[0], parts[1] - } else if len(parts) == 1 { - name = parts[0] - } else { - err = fmt.Errorf("invalid gateway name (name or namespace/name) found '%v'", gateway) - } - - return -} - -func (sc *virtualServiceSource) targetsFromIngress(ctx context.Context, ingressStr string, gateway *networkingv1alpha3.Gateway) (targets endpoint.Targets, err error) { +func (sc *virtualServiceSource) targetsFromIngress(ctx context.Context, 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) @@ -441,8 +429,11 @@ func (sc *virtualServiceSource) targetsFromIngress(ctx context.Context, ingressS ingress, err := sc.kubeClient.NetworkingV1().Ingresses(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { log.Error(err) - return + return nil, err } + + targets := make(endpoint.Targets, 0) + for _, lb := range ingress.Status.LoadBalancer.Ingress { if lb.IP != "" { targets = append(targets, lb.IP) @@ -450,10 +441,10 @@ func (sc *virtualServiceSource) targetsFromIngress(ctx context.Context, ingressS targets = append(targets, lb.Hostname) } } - return + return targets, nil } -func (sc *virtualServiceSource) targetsFromGateway(ctx context.Context, gateway *networkingv1alpha3.Gateway) (endpoint.Targets, error) { +func (sc *virtualServiceSource) targetsFromGateway(ctx context.Context, gateway *v1beta1.Gateway) (endpoint.Targets, error) { targets := annotations.TargetsFromTargetAnnotation(gateway.Annotations) if len(targets) > 0 { return targets, nil diff --git a/source/istio_virtualservice_test.go b/source/istio_virtualservice_test.go index 014b1d891..c4c01ba75 100644 --- a/source/istio_virtualservice_test.go +++ b/source/istio_virtualservice_test.go @@ -25,8 +25,8 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "istio.io/api/meta/v1alpha1" - istionetworking "istio.io/api/networking/v1alpha3" - networkingv1alpha3 "istio.io/client-go/pkg/apis/networking/v1alpha3" + 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" @@ -44,8 +44,8 @@ type VirtualServiceSuite struct { source Source lbServices []*v1.Service ingresses []*networkv1.Ingress - gwconfig *networkingv1alpha3.Gateway - vsconfig *networkingv1alpha3.VirtualService + gwconfig *networkingv1beta1.Gateway + vsconfig *networkingv1beta1.VirtualService } func (suite *VirtualServiceSuite) SetupTest() { @@ -98,7 +98,7 @@ func (suite *VirtualServiceSuite) SetupTest() { namespace: "istio-system", dnsnames: [][]string{{"*"}}, }).Config() - _, err = fakeIstioClient.NetworkingV1alpha3().Gateways(suite.gwconfig.Namespace).Create(context.Background(), suite.gwconfig, metav1.CreateOptions{}) + _, err = fakeIstioClient.NetworkingV1beta1().Gateways(suite.gwconfig.Namespace).Create(context.Background(), suite.gwconfig, metav1.CreateOptions{}) suite.NoError(err, "should succeed") suite.vsconfig = (fakeVirtualServiceConfig{ @@ -107,7 +107,7 @@ func (suite *VirtualServiceSuite) SetupTest() { gateways: []string{"istio-system/foo-gateway-with-targets"}, dnsnames: []string{"foo"}, }).Config() - _, err = fakeIstioClient.NetworkingV1alpha3().VirtualServices(suite.vsconfig.Namespace).Create(context.Background(), suite.vsconfig, metav1.CreateOptions{}) + _, err = fakeIstioClient.NetworkingV1beta1().VirtualServices(suite.vsconfig.Namespace).Create(context.Background(), suite.vsconfig, metav1.CreateOptions{}) suite.NoError(err, "should succeed") suite.source, err = NewIstioVirtualServiceSource( @@ -1948,8 +1948,8 @@ func testVirtualServiceEndpoints(t *testing.T) { t.Run(ti.title, func(t *testing.T) { t.Parallel() - var gateways []*networkingv1alpha3.Gateway - var virtualservices []*networkingv1alpha3.VirtualService + var gateways []*networkingv1beta1.Gateway + var virtualservices []*networkingv1beta1.VirtualService for _, gwItem := range ti.gwConfigs { gateways = append(gateways, gwItem.Config()) @@ -1958,7 +1958,7 @@ func testVirtualServiceEndpoints(t *testing.T) { virtualservices = append(virtualservices, vsItem.Config()) } - fakeKubernetesClient := fake.NewSimpleClientset() + fakeKubernetesClient := fake.NewClientset() for _, lb := range ti.lbServices { service := lb.Service() @@ -1975,12 +1975,12 @@ func testVirtualServiceEndpoints(t *testing.T) { fakeIstioClient := istiofake.NewSimpleClientset() for _, gateway := range gateways { - _, err := fakeIstioClient.NetworkingV1alpha3().Gateways(gateway.Namespace).Create(context.Background(), gateway, metav1.CreateOptions{}) + _, err := fakeIstioClient.NetworkingV1beta1().Gateways(gateway.Namespace).Create(context.Background(), gateway, metav1.CreateOptions{}) require.NoError(t, err) } - for _, virtualservice := range virtualservices { - _, err := fakeIstioClient.NetworkingV1alpha3().VirtualServices(virtualservice.Namespace).Create(context.Background(), virtualservice, metav1.CreateOptions{}) + for _, vService := range virtualservices { + _, err := fakeIstioClient.NetworkingV1beta1().VirtualServices(vService.Namespace).Create(context.Background(), vService, metav1.CreateOptions{}) require.NoError(t, err) } @@ -2041,7 +2041,7 @@ func testGatewaySelectorMatchesService(t *testing.T) { } func newTestVirtualServiceSource(loadBalancerList []fakeIngressGatewayService, ingressList []fakeIngress, gwList []fakeGatewayConfig) (*virtualServiceSource, error) { - fakeKubernetesClient := fake.NewSimpleClientset() + fakeKubernetesClient := fake.NewClientset() fakeIstioClient := istiofake.NewSimpleClientset() for _, lb := range loadBalancerList { @@ -2064,7 +2064,7 @@ func newTestVirtualServiceSource(loadBalancerList []fakeIngressGatewayService, i gwObj := gw.Config() // use create instead of add // https://github.com/kubernetes/client-go/blob/92512ee2b8cf6696e9909245624175b7f0c971d9/testing/fixture.go#LL336C3-L336C52 - _, err := fakeIstioClient.NetworkingV1alpha3().Gateways(gw.namespace).Create(context.Background(), gwObj, metav1.CreateOptions{}) + _, err := fakeIstioClient.NetworkingV1beta1().Gateways(gw.namespace).Create(context.Background(), gwObj, metav1.CreateOptions{}) if err != nil { return nil, err } @@ -2101,7 +2101,7 @@ type fakeVirtualServiceConfig struct { exportTo string } -func (c fakeVirtualServiceConfig) Config() *networkingv1alpha3.VirtualService { +func (c fakeVirtualServiceConfig) Config() *networkingv1beta1.VirtualService { vs := istionetworking.VirtualService{ Gateways: c.gateways, Hosts: c.dnsnames, @@ -2110,7 +2110,7 @@ func (c fakeVirtualServiceConfig) Config() *networkingv1alpha3.VirtualService { vs.ExportTo = []string{c.exportTo} } - return &networkingv1alpha3.VirtualService{ + return &networkingv1beta1.VirtualService{ ObjectMeta: metav1.ObjectMeta{ Name: c.name, Namespace: c.namespace, @@ -2127,13 +2127,13 @@ func TestVirtualServiceSourceGetGateway(t *testing.T) { type args struct { ctx context.Context gatewayStr string - virtualService *networkingv1alpha3.VirtualService + virtualService *networkingv1beta1.VirtualService } tests := []struct { name string fields fields args args - want *networkingv1alpha3.Gateway + want *networkingv1beta1.Gateway expectedErrStr string }{ {name: "EmptyGateway", fields: fields{ @@ -2155,7 +2155,7 @@ func TestVirtualServiceSourceGetGateway(t *testing.T) { }, args: args{ ctx: context.TODO(), gatewayStr: "doesnt/exist", - virtualService: &networkingv1alpha3.VirtualService{ + virtualService: &networkingv1beta1.VirtualService{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{Name: "exist", Namespace: "doesnt"}, Spec: istionetworking.VirtualService{}, @@ -2167,8 +2167,8 @@ func TestVirtualServiceSourceGetGateway(t *testing.T) { }, args: args{ ctx: context.TODO(), gatewayStr: "1/2/3/", - virtualService: &networkingv1alpha3.VirtualService{}, - }, want: nil, expectedErrStr: "invalid gateway name (name or namespace/name) found '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{{ @@ -2180,13 +2180,13 @@ func TestVirtualServiceSourceGetGateway(t *testing.T) { }, args: args{ ctx: context.TODO(), gatewayStr: "bar/foo", - virtualService: &networkingv1alpha3.VirtualService{ + virtualService: &networkingv1beta1.VirtualService{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}, Spec: istionetworking.VirtualService{}, Status: v1alpha1.IstioStatus{}, }, - }, want: &networkingv1alpha3.Gateway{ + }, want: &networkingv1beta1.Gateway{ TypeMeta: metav1.TypeMeta{}, ObjectMeta: metav1.ObjectMeta{Name: "foo", Namespace: "bar"}, Spec: istionetworking.Gateway{}, diff --git a/source/kong_tcpingress.go b/source/kong_tcpingress.go index 6bdc5886b..76efc4372 100644 --- a/source/kong_tcpingress.go +++ b/source/kong_tcpingress.go @@ -199,14 +199,14 @@ func (sc *kongTCPIngressSource) endpointsFromTCPIngress(tcpIngress *TCPIngress, if !sc.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(tcpIngress.Annotations) for _, hostname := range hostnameList { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } if tcpIngress.Spec.Rules != nil { for _, rule := range tcpIngress.Spec.Rules { if rule.Host != "" { - endpoints = append(endpoints, endpointsForHostname(rule.Host, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(rule.Host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } } diff --git a/source/node.go b/source/node.go index 53a438d4c..28c879f94 100644 --- a/source/node.go +++ b/source/node.go @@ -27,7 +27,6 @@ import ( kubeinformers "k8s.io/client-go/informers" coreinformers "k8s.io/client-go/informers/core/v1" "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/source/annotations" @@ -69,13 +68,7 @@ func NewNodeSource( nodeInformer := informerFactory.Core().V1().Nodes() // Add default resource event handler to properly initialize informer. - nodeInformer.Informer().AddEventHandler( - cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - log.Debug("node added") - }, - }, - ) + _, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) @@ -172,7 +165,8 @@ func (ns *nodeSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) return endpointsSlice, nil } -func (ns *nodeSource) AddEventHandler(_ context.Context, _ func()) { +func (ns *nodeSource) AddEventHandler(_ context.Context, handler func()) { + _, _ = ns.nodeInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } // nodeAddress returns the node's externalIP and if that's not found, the node's internalIP diff --git a/source/node_test.go b/source/node_test.go index 160d5994b..b362cdfd8 100644 --- a/source/node_test.go +++ b/source/node_test.go @@ -26,7 +26,10 @@ import ( log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/mock" "k8s.io/client-go/kubernetes" + corev1lister "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/internal/testutils" @@ -619,6 +622,39 @@ func TestResourceLabelIsSetForEachNodeEndpoint(t *testing.T) { } } +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 + informer cache.SharedIndexInformer +} + +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 } diff --git a/source/openshift_route.go b/source/openshift_route.go index 40e12fec8..baa485cd8 100644 --- a/source/openshift_route.go +++ b/source/openshift_route.go @@ -190,7 +190,7 @@ func (ors *ocpRouteSource) endpointsFromTemplate(ocpRoute *routev1.Route) ([]*en var endpoints []*endpoint.Endpoint for _, hostname := range hostnames { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } @@ -236,14 +236,14 @@ func (ors *ocpRouteSource) endpointsFromOcpRoute(ocpRoute *routev1.Route, ignore providerSpecific, setIdentifier := annotations.ProviderSpecificAnnotations(ocpRoute.Annotations) if host != "" { - endpoints = append(endpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, 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, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } return endpoints diff --git a/source/pod.go b/source/pod.go index 9a52653c0..baa0e58e5 100644 --- a/source/pod.go +++ b/source/pod.go @@ -25,15 +25,14 @@ import ( log "github.com/sirupsen/logrus" corev1 "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" - "k8s.io/client-go/tools/cache" - - "sigs.k8s.io/external-dns/source/fqdn" "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" ) @@ -60,23 +59,24 @@ func NewPodSource( podSourceDomain string, fqdnTemplate string, combineFqdnAnnotation bool, + annotationFilter string, + labelSelector labels.Selector, ) (Source, error) { informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace)) podInformer := informerFactory.Core().V1().Pods() nodeInformer := informerFactory.Core().V1().Nodes() - _, _ = podInformer.Informer().AddEventHandler( - cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - }, - }, - ) - _, _ = nodeInformer.Informer().AddEventHandler( - cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - }, - }, - ) + 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()) + _, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) informerFactory.Start(ctx.Done()) @@ -103,17 +103,20 @@ func NewPodSource( }, nil } -func (*podSource) AddEventHandler(_ context.Context, _ func()) { +func (ps *podSource) AddEventHandler(_ context.Context, handler func()) { + _, _ = ps.podInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } func (ps *podSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) { - pods, err := ps.podInformer.Lister().Pods(ps.namespace).List(labels.Everything()) - if err != nil { - return nil, err - } + indexKeys := ps.podInformer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors) endpointMap := make(map[endpoint.EndpointKey][]string) - for _, pod := range pods { + for _, key := range indexKeys { + pod, err := informers.GetByKey[*corev1.Pod](ps.podInformer.Informer().GetIndexer(), key) + if err != nil { + continue + } + if ps.fqdnTemplate == nil || ps.combineFQDNAnnotation { ps.addPodEndpointsToEndpointMap(endpointMap, pod) } diff --git a/source/pod_fqdn_test.go b/source/pod_fqdn_test.go index 28f656220..3343fcba0 100644 --- a/source/pod_fqdn_test.go +++ b/source/pod_fqdn_test.go @@ -58,7 +58,9 @@ func TestNewPodSourceWithFqdn(t *testing.T) { false, "", tt.fqdnTemplate, - false) + false, + "", + nil) if tt.expectError { assert.Error(t, err) @@ -405,7 +407,9 @@ func TestPodSourceFqdnTemplatingExamples(t *testing.T) { false, tt.sourceDomain, tt.fqdnTemplate, - tt.combineFQDN) + tt.combineFQDN, + "", + nil) require.NoError(t, err) endpoints, err := src.Endpoints(t.Context()) @@ -467,7 +471,9 @@ func TestPodSourceFqdnTemplatingExamples_Failed(t *testing.T) { false, tt.sourceDomain, tt.fqdnTemplate, - tt.combineFQDN) + tt.combineFQDN, + "", + nil) require.NoError(t, err) _, err = src.Endpoints(t.Context()) diff --git a/source/pod_indexer_test.go b/source/pod_indexer_test.go new file mode 100644 index 000000000..402337a39 --- /dev/null +++ b/source/pod_indexer_test.go @@ -0,0 +1,232 @@ +/* +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 := 0; i < totalPods; i++ { + pods = append(pods, createPod(i, el)) + } + } + + for i := 0; i < 3; i++ { + 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, + tt.namespace, "", + false, "", + "{{ .Name }}.tld.org", false, + tt.annotationFilter, selector) + require.NoError(t, err) + + endpoints, err := pSource.Endpoints(t.Context()) + require.NoError(t, err) + + assert.Len(t, endpoints, tt.expectedEndpointCount) + }) + } +} diff --git a/source/pod_test.go b/source/pod_test.go index 40592279e..e6e0f8b06 100644 --- a/source/pod_test.go +++ b/source/pod_test.go @@ -23,9 +23,13 @@ import ( "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" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1lister "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" @@ -657,7 +661,7 @@ func TestPodSource(t *testing.T) { } } - client, err := NewPodSource(ctx, kubernetes, tc.targetNamespace, tc.compatibility, tc.ignoreNonHostNetworkPods, tc.PodSourceDomain, "", false) + client, err := NewPodSource(ctx, kubernetes, tc.targetNamespace, tc.compatibility, tc.ignoreNonHostNetworkPods, tc.PodSourceDomain, "", false, "", nil) require.NoError(t, err) endpoints, err := client.Endpoints(ctx) @@ -885,7 +889,7 @@ func TestPodSourceLogs(t *testing.T) { } } - client, err := NewPodSource(ctx, kubernetes, "", "", tc.ignoreNonHostNetworkPods, "", "", false) + client, err := NewPodSource(ctx, kubernetes, "", "", tc.ignoreNonHostNetworkPods, "", "", false, "", nil) require.NoError(t, err) hook := testutils.LogsUnderTestWithLogLevel(log.DebugLevel, t) @@ -909,6 +913,39 @@ func TestPodSourceLogs(t *testing.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 + informer cache.SharedIndexInformer +} + +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{ { diff --git a/source/service.go b/source/service.go index 942558552..a2a8f8f7f 100644 --- a/source/service.go +++ b/source/service.go @@ -29,7 +29,6 @@ import ( 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" "k8s.io/apimachinery/pkg/types" kubeinformers "k8s.io/client-go/informers" @@ -97,56 +96,52 @@ func NewServiceSource(ctx context.Context, kubeClient kubernetes.Interface, name // 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() - endpointSlicesInformer := informerFactory.Discovery().V1().EndpointSlices() - podInformer := informerFactory.Core().V1().Pods() - nodeInformer := informerFactory.Core().V1().Nodes() // Add default resource event handlers to properly initialize informer. - serviceInformer.Informer().AddEventHandler( - cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - }, - }, - ) - endpointSlicesInformer.Informer().AddEventHandler( - cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - }, - }, - ) - podInformer.Informer().AddEventHandler( - cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - }, - }, - ) - nodeInformer.Informer().AddEventHandler( - cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - }, - }, - ) + _, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler()) - // 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 := types.NamespacedName{Namespace: endpointSlice.Namespace, Name: serviceName}.String() - return []string{key}, nil - }, - }) + // Transform the slice into a map so it will be way much easier and fast to filter later + sTypesFilter, err := newServiceTypesFilter(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()) + + // 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 := types.NamespacedName{Namespace: endpointSlice.Namespace, Name: serviceName}.String() + return []string{key}, nil + }, + }) + if err != nil { + return nil, err + } + } + + 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. @@ -154,12 +149,6 @@ func NewServiceSource(ctx context.Context, kubeClient kubernetes.Interface, name return nil, err } - // Transform the slice into a map so it will be way much easier and fast to filter later - sTypesFilter, err := newServiceTypesFilter(serviceTypeFilter) - if err != nil { - return nil, err - } - return &serviceSource{ client: kubeClient, namespace: namespace, @@ -198,7 +187,7 @@ func (sc *serviceSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err return nil, err } - endpoints := []*endpoint.Endpoint{} + endpoints := make([]*endpoint.Endpoint, 0) for _, svc := range services { // Check controller annotation to see if we are responsible. @@ -252,6 +241,29 @@ func (sc *serviceSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err 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].UniqueOrderedTargets() + mergedEndpoints[key] = existing + } else { + ep.UniqueOrderedTargets() + 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 { @@ -259,31 +271,6 @@ func (sc *serviceSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err } return endpoints[i].RecordType < endpoints[j].RecordType }) - mergedEndpoints := []*endpoint.Endpoint{} - mergedEndpoints = append(mergedEndpoints, endpoints[0]) - for i := 1; i < len(endpoints); i++ { - lastMergedEndpoint := len(mergedEndpoints) - 1 - if mergedEndpoints[lastMergedEndpoint].DNSName == endpoints[i].DNSName && - mergedEndpoints[lastMergedEndpoint].RecordType == endpoints[i].RecordType && - mergedEndpoints[lastMergedEndpoint].RecordType != endpoint.RecordTypeCNAME && // It is against RFC-1034 for CNAME records to have multiple targets, so skip merging - mergedEndpoints[lastMergedEndpoint].SetIdentifier == endpoints[i].SetIdentifier && - mergedEndpoints[lastMergedEndpoint].RecordTTL == endpoints[i].RecordTTL { - mergedEndpoints[lastMergedEndpoint].Targets = append(mergedEndpoints[lastMergedEndpoint].Targets, endpoints[i].Targets[0]) - } else { - mergedEndpoints = append(mergedEndpoints, endpoints[i]) - } - - if mergedEndpoints[lastMergedEndpoint].DNSName == endpoints[i].DNSName && - mergedEndpoints[lastMergedEndpoint].RecordType == endpoints[i].RecordType && - mergedEndpoints[lastMergedEndpoint].RecordType == endpoint.RecordTypeCNAME { - log.Debugf("CNAME %s with multiple targets found", endpoints[i].DNSName) - } - } - endpoints = mergedEndpoints - } - - for _, ep := range endpoints { - sort.Sort(ep.Targets) } return endpoints, nil @@ -293,11 +280,7 @@ func (sc *serviceSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err func (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname string, ttl endpoint.TTL) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint - labelSelector, err := metav1.ParseToLabelSelector(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String()) - if err != nil { - return nil - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String()) if err != nil { return nil } @@ -371,6 +354,10 @@ func (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname stri targets := annotations.TargetsFromTargetAnnotation(pod.Annotations) if len(targets) == 0 { if endpointsType == EndpointsTypeNodeExternalIP { + if sc.nodeInformer == nil { + log.Warnf("Skipping EndpointSlice %s/%s as --service-type-filter disable node informer", endpointSlice.Namespace, endpointSlice.Name) + continue + } 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) @@ -466,7 +453,8 @@ func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service) ([]*endpoint.End // 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 + + // Skip endpoints if we do not want entries from annotations or service is excluded if sc.ignoreHostnameAnnotation { return endpoints } @@ -519,7 +507,7 @@ func (sc *serviceSource) filterByServiceType(services []*v1.Service) []*v1.Servi } var result []*v1.Service for _, service := range services { - if _, ok := sc.serviceTypeFilter.types[service.Spec.Type]; ok { + if sc.serviceTypeFilter.isProcessed(service.Spec.Type) { result = append(result, service) } } @@ -527,7 +515,7 @@ func (sc *serviceSource) filterByServiceType(services []*v1.Service) []*v1.Servi return result } -func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, providerSpecific endpoint.ProviderSpecific, setIdentifier string, useClusterIP bool) (endpoints []*endpoint.Endpoint) { +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) @@ -536,6 +524,8 @@ func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, pro targets := annotations.TargetsFromTargetAnnotation(svc.Annotations) + endpoints := make([]*endpoint.Endpoint, 0) + if len(targets) == 0 { switch svc.Spec.Type { case v1.ServiceTypeLoadBalancer: @@ -569,7 +559,7 @@ func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, pro } } - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) return endpoints } @@ -696,11 +686,7 @@ func (sc *serviceSource) nodesExternalTrafficPolicyTypeLocal(svc *v1.Service) [] // pods retrieve a slice of pods associated with the given Service func (sc *serviceSource) pods(svc *v1.Service) []*v1.Pod { - labelSelector, err := metav1.ParseToLabelSelector(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String()) - if err != nil { - return nil - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String()) if err != nil { return nil } @@ -747,13 +733,19 @@ func (sc *serviceSource) extractNodePortTargets(svc *v1.Service) (endpoint.Targe access := getAccessFromAnnotations(svc.Annotations) switch access { case "public": - return append(externalIPs, ipv6IPs...), nil + if sc.exposeInternalIPv6 { + return append(externalIPs, ipv6IPs...), nil + } + return externalIPs, nil case "private": return internalIPs, nil } if len(externalIPs) > 0 { - return append(externalIPs, ipv6IPs...), nil + if sc.exposeInternalIPv6 { + return append(externalIPs, ipv6IPs...), nil + } + return externalIPs, nil } return internalIPs, nil @@ -806,9 +798,12 @@ func (sc *serviceSource) 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 - sc.serviceInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) - if sc.listenEndpointEvents { - sc.endpointSlicesInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) + _, _ = sc.serviceInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) + if sc.listenEndpointEvents && sc.serviceTypeFilter.isRequired(v1.ServiceTypeNodePort, v1.ServiceTypeClusterIP) { + _, _ = sc.endpointSlicesInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) + } + if sc.serviceTypeFilter.isRequired(v1.ServiceTypeNodePort) { + _, _ = sc.nodeInformer.Informer().AddEventHandler(eventHandlerFunc(handler)) } } @@ -826,20 +821,38 @@ func newServiceTypesFilter(filter []string) (*serviceTypes, error) { enabled: false, }, nil } - types := make(map[v1.ServiceType]bool) + 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))) } - types[v1.ServiceType(serviceType)] = true + result[v1.ServiceType(serviceType)] = true } return &serviceTypes{ enabled: true, - types: types, + 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 { diff --git a/source/service_test.go b/source/service_test.go index 885af6959..07df803bd 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -30,11 +30,14 @@ import ( "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/util/intstr" "k8s.io/client-go/kubernetes/fake" + "sigs.k8s.io/external-dns/source/informers" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" @@ -249,7 +252,7 @@ func testServiceSourceEndpoints(t *testing.T) { }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, - serviceTypesFilter: []string{}, + serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)}, expected: []*endpoint.Endpoint{ {DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, @@ -294,12 +297,24 @@ func testServiceSourceEndpoints(t *testing.T) { annotations: map[string]string{}, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, - serviceTypesFilter: []string{}, + 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", @@ -455,7 +470,7 @@ func testServiceSourceEndpoints(t *testing.T) { }, externalIPs: []string{}, lbs: []string{"1.2.3.4"}, - serviceTypesFilter: []string{}, + 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"}}, }, @@ -920,7 +935,6 @@ func testServiceSourceEndpoints(t *testing.T) { annotations: map[string]string{ hostnameAnnotationKey: "foo.example.org.", }, - externalIPs: []string{}, lbs: []string{"1.2.3.4"}, serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)}, expected: []*endpoint.Endpoint{}, @@ -1722,7 +1736,7 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, RecordType: endpoint.RecordTypeAAAA}, + {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ @@ -1732,7 +1746,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { @@ -1743,7 +1758,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, @@ -1766,7 +1782,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { @@ -1777,7 +1794,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, @@ -1793,7 +1811,7 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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::2"}, RecordType: endpoint.RecordTypeAAAA}, + {DNSName: "foo.bar.example.com", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ @@ -1803,7 +1821,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { @@ -1814,7 +1833,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, @@ -1867,7 +1887,7 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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::2"}, RecordType: endpoint.RecordTypeAAAA}, + {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ @@ -1877,7 +1897,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { @@ -1888,7 +1909,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, @@ -1911,7 +1933,7 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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::2"}, RecordType: endpoint.RecordTypeAAAA}, + {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ @@ -1921,7 +1943,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { @@ -1932,7 +1955,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::3"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::4"}, }, }, }}, @@ -2085,7 +2109,7 @@ func TestServiceSourceNodePortServices(t *testing.T) { }}, }, { - title: "access=public annotation NodePort services return an endpoint with public IP addresses of the cluster's nodes", + 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, @@ -2098,7 +2122,7 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, RecordType: endpoint.RecordTypeAAAA}, + {DNSName: "foo.example.org", Targets: endpoint.Targets{"2001:DB8::1", "2001:DB8::3"}, RecordType: endpoint.RecordTypeAAAA}, }, nodes: []*v1.Node{{ ObjectMeta: metav1.ObjectMeta{ @@ -2108,7 +2132,8 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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"}, + {Type: v1.NodeExternalIP, Address: "2001:DB8::1"}, + {Type: v1.NodeInternalIP, Address: "2001:DB8::2"}, }, }, }, { @@ -2119,9 +2144,53 @@ func TestServiceSourceNodePortServices(t *testing.T) { 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{ + hostnameAnnotationKey: "foo.example.org.", + accessAnnotationKey: "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"}, + }, + }, }}, }, { @@ -2430,6 +2499,7 @@ func TestHeadlessServices(t *testing.T) { podsReady []bool publishNotReadyAddresses bool nodes []v1.Node + serviceTypesFilter []string expected []*endpoint.Endpoint expectError bool }{ @@ -2460,6 +2530,7 @@ func TestHeadlessServices(t *testing.T) { []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"}}, @@ -2494,6 +2565,7 @@ func TestHeadlessServices(t *testing.T) { []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"}}, @@ -2528,6 +2600,7 @@ func TestHeadlessServices(t *testing.T) { []bool{true, true}, false, []v1.Node{}, + []string{}, []*endpoint.Endpoint{}, false, }, @@ -2559,6 +2632,7 @@ func TestHeadlessServices(t *testing.T) { []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)}, @@ -2594,6 +2668,7 @@ func TestHeadlessServices(t *testing.T) { []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)}, @@ -2628,6 +2703,7 @@ func TestHeadlessServices(t *testing.T) { []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"}}, @@ -2661,6 +2737,7 @@ func TestHeadlessServices(t *testing.T) { []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"}}, @@ -2695,6 +2772,7 @@ func TestHeadlessServices(t *testing.T) { []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"}}, }, @@ -2727,6 +2805,7 @@ func TestHeadlessServices(t *testing.T) { []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"}}, }, @@ -2759,6 +2838,7 @@ func TestHeadlessServices(t *testing.T) { []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"}}, }, @@ -2793,6 +2873,7 @@ func TestHeadlessServices(t *testing.T) { []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"}}, }, @@ -2827,6 +2908,7 @@ func TestHeadlessServices(t *testing.T) { []bool{true, true, true}, false, []v1.Node{}, + []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}}, }, @@ -2871,6 +2953,7 @@ func TestHeadlessServices(t *testing.T) { }, }, }, + []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, @@ -2919,6 +3002,7 @@ func TestHeadlessServices(t *testing.T) { }, }, }, + []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::5"}}, }, @@ -2963,6 +3047,7 @@ func TestHeadlessServices(t *testing.T) { }, }, }, + []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}}, }, @@ -3011,6 +3096,7 @@ func TestHeadlessServices(t *testing.T) { }, }, }, + []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"}}, @@ -3045,6 +3131,7 @@ func TestHeadlessServices(t *testing.T) { []bool{true, true, true}, false, []v1.Node{}, + []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}}, }, @@ -3078,6 +3165,7 @@ func TestHeadlessServices(t *testing.T) { []bool{true, true, true}, false, []v1.Node{}, + []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}}, }, @@ -3089,7 +3177,7 @@ func TestHeadlessServices(t *testing.T) { t.Parallel() // Create a Kubernetes testing client - kubernetes := fake.NewSimpleClientset() + kubernetes := fake.NewClientset() service := &v1.Service{ Spec: v1.ServiceSpec{ @@ -3174,7 +3262,7 @@ func TestHeadlessServices(t *testing.T) { true, false, false, - []string{}, + tc.serviceTypesFilter, tc.ignoreHostnameAnnotation, labels.Everything(), false, @@ -3196,6 +3284,378 @@ func TestHeadlessServices(t *testing.T) { } } +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(context.Background(), svc, metav1.CreateOptions{}) + require.NoError(t, err) + } + + for _, pod := range pods { + _, err := kubernetes.CoreV1().Pods(pod.Namespace).Create(context.Background(), pod, metav1.CreateOptions{}) + require.NoError(t, err) + } + + for _, ep := range endpoints { + _, err := kubernetes.DiscoveryV1().EndpointSlices(ep.Namespace).Create(context.Background(), ep, metav1.CreateOptions{}) + require.NoError(t, err) + } + + src, err := NewServiceSource( + t.Context(), + kubernetes, + v1.NamespaceAll, + "", + "", + false, + "", + false, + false, + false, + []string{}, + false, + labels.Everything(), + false, + false, + false, + ) + require.NoError(t, err) + assert.NotNil(t, src) + + got, err := src.Endpoints(context.Background()) + 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() @@ -3554,7 +4014,6 @@ func TestHeadlessServicesHostIP(t *testing.T) { t.Run(tc.title, func(t *testing.T) { t.Parallel() - // Create a Kubernetes testing client kubernetes := fake.NewClientset() service := &v1.Service{ @@ -3675,6 +4134,7 @@ func TestExternalServices(t *testing.T) { annotations map[string]string externalName string externalIPs []string + serviceTypeFilter []string expected []*endpoint.Endpoint expectError bool }{ @@ -3693,6 +4153,7 @@ func TestExternalServices(t *testing.T) { }, "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}, }, @@ -3713,6 +4174,7 @@ func TestExternalServices(t *testing.T) { }, "2001:db8::111", []string{}, + []string{}, []*endpoint.Endpoint{ {DNSName: "service.example.org", Targets: endpoint.Targets{"2001:db8::111"}, RecordType: endpoint.RecordTypeAAAA}, }, @@ -3733,6 +4195,7 @@ func TestExternalServices(t *testing.T) { }, "remote.example.com", []string{}, + []string{string(v1.ServiceTypeExternalName)}, []*endpoint.Endpoint{ {DNSName: "service.example.org", Targets: endpoint.Targets{"remote.example.com"}, RecordType: endpoint.RecordTypeCNAME}, }, @@ -3753,6 +4216,7 @@ func TestExternalServices(t *testing.T) { }, "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"}}, }, @@ -3773,12 +4237,32 @@ func TestExternalServices(t *testing.T) { }, "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{ + hostnameAnnotationKey: "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) { @@ -3816,7 +4300,7 @@ func TestExternalServices(t *testing.T) { true, false, false, - []string{}, + tc.serviceTypeFilter, tc.ignoreHostnameAnnotation, labels.Everything(), false, @@ -3893,6 +4377,107 @@ func BenchmarkServiceEndpoints(b *testing.B) { } } +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 _, ts := range tests { + t.Run(ts.name, func(t *testing.T) { + svc, err := NewServiceSource( + t.Context(), + fake.NewClientset(), + "default", + "", + "", + false, + "", + true, + false, + false, + ts.svcFilter, + false, + labels.Everything(), + false, + false, + false, + ) + require.NoError(t, err) + svcSrc, ok := svc.(*serviceSource) + if !ok { + require.Fail(t, "expected serviceSource") + } + ts.asserts(svcSrc) + }) + } +} + func TestNewServiceSourceWithServiceTypeFilters_Unsupported(t *testing.T) { serviceTypeFilter := []string{"ClusterIP", "ServiceTypeNotExist"} @@ -4146,3 +4731,129 @@ func createTestServicesByType(namespace string, typeCounts map[v1.ServiceType]in }) 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, s *serviceSource) + }{ + { + name: "AddEventHandler should trigger all event handlers when empty filter is provided", + filter: []string{}, + times: 3, + asserts: func(t *testing.T, s *serviceSource) { + fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1) + fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 1) + fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 1) + }, + }, + { + name: "AddEventHandler should trigger only service event handler", + filter: []string{string(v1.ServiceTypeExternalName), string(v1.ServiceTypeLoadBalancer)}, + times: 1, + asserts: func(t *testing.T, s *serviceSource) { + 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, s *serviceSource) { + 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: 3, + asserts: func(t *testing.T, s *serviceSource) { + fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1) + fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 1) + fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 1) + }, + }, + } + 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, svcSource) + }) + } +} diff --git a/source/skipper_routegroup.go b/source/skipper_routegroup.go index bf5b37ef7..de7c9f620 100644 --- a/source/skipper_routegroup.go +++ b/source/skipper_routegroup.go @@ -320,7 +320,7 @@ func (sc *routeGroupSource) endpointsFromTemplate(rg *routeGroup) ([]*endpoint.E hostnameList := strings.Split(strings.ReplaceAll(hostnames, " ", ""), ",") for _, hostname := range hostnameList { hostname = strings.TrimSuffix(hostname, ".") - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } return endpoints, nil } @@ -351,14 +351,14 @@ func (sc *routeGroupSource) endpointsFromRouteGroup(rg *routeGroup) []*endpoint. if src == "" { continue } - endpoints = append(endpoints, endpointsForHostname(src, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, 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, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } return endpoints diff --git a/source/store.go b/source/store.go index 82a03f1b9..c77e89c3b 100644 --- a/source/store.go +++ b/source/store.go @@ -22,12 +22,11 @@ import ( "fmt" "net/http" "os" - "strings" + "sync" "time" "github.com/cloudfoundry-community/go-cfclient" - "github.com/linki/instrumented_http" openshift "github.com/openshift/client-go/route/clientset/versioned" log "github.com/sirupsen/logrus" istioclient "istio.io/client-go/pkg/clientset/versioned" @@ -38,6 +37,8 @@ import ( "k8s.io/client-go/tools/clientcmd" gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" + extdnshttp "sigs.k8s.io/external-dns/pkg/http" + "sigs.k8s.io/external-dns/pkg/apis/externaldns" ) @@ -45,6 +46,20 @@ import ( 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 @@ -82,7 +97,7 @@ type Config struct { OCPRouterName string UpdateEvents bool ResolveLoadBalancerHostname bool - TraefikDisableLegacy bool + TraefikEnableLegacy bool TraefikDisableNew bool ExcludeUnschedulable bool ExposeInternalIPv6 bool @@ -128,14 +143,28 @@ func NewSourceConfig(cfg *externaldns.Config) *Config { OCPRouterName: cfg.OCPRouterName, UpdateEvents: cfg.UpdateEvents, ResolveLoadBalancerHostname: cfg.ResolveServiceLoadBalancerHostname, - TraefikDisableLegacy: cfg.TraefikDisableLegacy, + TraefikEnableLegacy: cfg.TraefikEnableLegacy, TraefikDisableNew: cfg.TraefikDisableNew, ExcludeUnschedulable: cfg.ExcludeUnschedulable, ExposeInternalIPv6: cfg.ExposeInternalIPV6, } } -// ClientGenerator provides clients +// 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 +// - CloudFoundryClient: CloudFoundry platform client +// - DynamicKubernetesClient: Dynamic client for custom resources +// - OpenShiftClient: OpenShift-specific client for Route resources +// +// 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) @@ -145,8 +174,17 @@ type ClientGenerator interface { OpenShiftClient() (openshift.Interface, error) } -// SingletonClientGenerator stores provider clients and guarantees that only one instance of client -// will be generated +// 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. type SingletonClientGenerator struct { KubeConfig string APIServerURL string @@ -261,33 +299,47 @@ func ByNames(ctx context.Context, p ClientGenerator, names []string, cfg *Config return sources, nil } -// BuildWithConfig allows generating a Source implementation from the shared config +// 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) +// - "cloudfoundry": CloudFoundry applications +// - "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 "node": - client, err := p.KubeClient() - if err != nil { - return nil, err - } - return NewNodeSource(ctx, client, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.LabelFilter, cfg.ExposeInternalIPv6, cfg.ExcludeUnschedulable, cfg.CombineFQDNAndAnnotation) + return buildNodeSource(ctx, p, cfg) case "service": - client, err := p.KubeClient() - if err != nil { - return nil, err - } - return NewServiceSource(ctx, client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.Compatibility, cfg.PublishInternal, cfg.PublishHostIP, cfg.AlwaysPublishNotReadyAddresses, cfg.ServiceTypeFilter, cfg.IgnoreHostnameAnnotation, cfg.LabelFilter, cfg.ResolveLoadBalancerHostname, cfg.ListenEndpointEvents, cfg.ExposeInternalIPv6) + return buildServiceSource(ctx, p, cfg) case "ingress": - client, err := p.KubeClient() - if err != nil { - return nil, err - } - return NewIngressSource(ctx, client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation, cfg.IgnoreIngressTLSSpec, cfg.IgnoreIngressRulesSpec, cfg.LabelFilter, cfg.IngressClassNames) + return buildIngressSource(ctx, p, cfg) case "pod": - client, err := p.KubeClient() - if err != nil { - return nil, err - } - return NewPodSource(ctx, client, cfg.Namespace, cfg.Compatibility, cfg.IgnoreNonHostNetworkPods, cfg.PodSourceDomain, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation) + return buildPodSource(ctx, p, cfg) case "gateway-httproute": return NewGatewayHTTPRouteSource(p, cfg) case "gateway-grpcroute": @@ -299,152 +351,298 @@ func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg case "gateway-udproute": return NewGatewayUDPRouteSource(p, cfg) case "istio-gateway": - 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.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) + return buildIstioGatewaySource(ctx, p, cfg) case "istio-virtualservice": - 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.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) + return buildIstioVirtualServiceSource(ctx, p, cfg) case "cloudfoundry": - cfClient, err := p.CloudFoundryClient(cfg.CFAPIEndpoint, cfg.CFUsername, cfg.CFPassword) - if err != nil { - return nil, err - } - return NewCloudFoundrySource(cfClient) + return buildCloudFoundrySource(ctx, p, cfg) case "ambassador-host": - 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.Namespace, cfg.AnnotationFilter, cfg.LabelFilter) + return buildAmbassadorHostSource(ctx, p, cfg) case "contour-httpproxy": - dynamicClient, err := p.DynamicKubernetesClient() - if err != nil { - return nil, err - } - return NewContourHTTPProxySource(ctx, dynamicClient, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) + return buildContourHTTPProxySource(ctx, p, cfg) case "gloo-proxy": - kubernetesClient, err := p.KubeClient() - if err != nil { - return nil, err - } - dynamicClient, err := p.DynamicKubernetesClient() - if err != nil { - return nil, err - } - return NewGlooSource(dynamicClient, kubernetesClient, cfg.GlooNamespaces) + return buildGlooProxySource(ctx, p, cfg) case "traefik-proxy": - 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.Namespace, cfg.AnnotationFilter, cfg.IgnoreHostnameAnnotation, cfg.TraefikDisableLegacy, cfg.TraefikDisableNew) + return buildTraefikProxySource(ctx, p, cfg) case "openshift-route": - ocpClient, err := p.OpenShiftClient() - if err != nil { - return nil, err - } - return NewOcpRouteSource(ctx, ocpClient, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation, cfg.LabelFilter, cfg.OCPRouterName) + return buildOpenShiftRouteSource(ctx, p, cfg) case "fake": return NewFakeSource(cfg.FQDNTemplate) case "connector": return NewConnectorSource(cfg.ConnectorServer) case "crd": - client, err := p.KubeClient() - if err != nil { - return nil, err - } - crdClient, scheme, err := NewCRDClientForAPIVersionKind(client, cfg.KubeConfig, cfg.APIServerURL, cfg.CRDSourceAPIVersion, cfg.CRDSourceKind) - if err != nil { - return nil, err - } - return NewCRDSource(crdClient, cfg.Namespace, cfg.CRDSourceKind, cfg.AnnotationFilter, cfg.LabelFilter, scheme, cfg.UpdateEvents) + return buildCRDSource(ctx, p, cfg) case "skipper-routegroup": - apiServerURL := cfg.APIServerURL - tokenPath := "" - token := "" - restConfig, err := GetRestConfig(cfg.KubeConfig, cfg.APIServerURL) - if err == nil { - apiServerURL = restConfig.Host - tokenPath = restConfig.BearerTokenFile - token = restConfig.BearerToken - } - return NewRouteGroupSource(cfg.RequestTimeout, token, tokenPath, apiServerURL, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.SkipperRouteGroupVersion, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) + return buildSkipperRouteGroupSource(ctx, cfg) case "kong-tcpingress": - 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.Namespace, cfg.AnnotationFilter, cfg.IgnoreHostnameAnnotation) + return buildKongTCPIngressSource(ctx, p, cfg) case "f5-virtualserver": - 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.Namespace, cfg.AnnotationFilter) + return buildF5VirtualServerSource(ctx, p, cfg) case "f5-transportserver": - 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.Namespace, cfg.AnnotationFilter) + return buildF5TransportServerSource(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.AnnotationFilter, cfg.FQDNTemplate, cfg.LabelFilter, cfg.ExposeInternalIPv6, cfg.ExcludeUnschedulable, cfg.CombineFQDNAndAnnotation) +} + +// 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.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.Compatibility, cfg.PublishInternal, cfg.PublishHostIP, cfg.AlwaysPublishNotReadyAddresses, cfg.ServiceTypeFilter, cfg.IgnoreHostnameAnnotation, cfg.LabelFilter, cfg.ResolveLoadBalancerHostname, cfg.ListenEndpointEvents, cfg.ExposeInternalIPv6) +} + +// 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.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation, cfg.IgnoreIngressTLSSpec, cfg.IgnoreIngressRulesSpec, cfg.LabelFilter, cfg.IngressClassNames) +} + +// 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.Namespace, cfg.Compatibility, cfg.IgnoreNonHostNetworkPods, cfg.PodSourceDomain, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.AnnotationFilter, cfg.LabelFilter) +} + +// 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.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) +} + +// 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.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) +} + +// buildCloudFoundrySource creates a CloudFoundry source for exposing CF applications as DNS records. +// Uses CloudFoundry client instead of Kubernetes client. Simple constructor with minimal parameters. +func buildCloudFoundrySource(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { + cfClient, err := p.CloudFoundryClient(cfg.CFAPIEndpoint, cfg.CFUsername, cfg.CFPassword) + if err != nil { + return nil, err + } + return NewCloudFoundrySource(cfClient) +} + +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.Namespace, cfg.AnnotationFilter, cfg.LabelFilter) +} + +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.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) +} + +// 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(dynamicClient, kubernetesClient, cfg.GlooNamespaces) +} + +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.Namespace, cfg.AnnotationFilter, cfg.IgnoreHostnameAnnotation, cfg.TraefikEnableLegacy, cfg.TraefikDisableNew) +} + +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.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation, cfg.LabelFilter, cfg.OCPRouterName) +} + +// 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(ctx context.Context, p ClientGenerator, cfg *Config) (Source, error) { + client, err := p.KubeClient() + if err != nil { + return nil, err + } + crdClient, scheme, err := NewCRDClientForAPIVersionKind(client, cfg.KubeConfig, cfg.APIServerURL, cfg.CRDSourceAPIVersion, cfg.CRDSourceKind) + if err != nil { + return nil, err + } + return NewCRDSource(crdClient, cfg.Namespace, cfg.CRDSourceKind, cfg.AnnotationFilter, cfg.LabelFilter, scheme, cfg.UpdateEvents) +} + +// 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(ctx context.Context, cfg *Config) (Source, error) { + apiServerURL := cfg.APIServerURL + tokenPath := "" + token := "" + restConfig, err := GetRestConfig(cfg.KubeConfig, cfg.APIServerURL) + if err == nil { + apiServerURL = restConfig.Host + tokenPath = restConfig.BearerTokenFile + token = restConfig.BearerToken + } + return NewRouteGroupSource(cfg.RequestTimeout, token, tokenPath, apiServerURL, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.SkipperRouteGroupVersion, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) +} + +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.Namespace, cfg.AnnotationFilter, cfg.IgnoreHostnameAnnotation) +} + +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.Namespace, cfg.AnnotationFilter) +} + +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.Namespace, cfg.AnnotationFilter) +} + +// instrumentedRESTConfig creates a REST config with request instrumentation for monitoring. +// Adds HTTP transport wrapper for Prometheus metrics collection and request timeout configuration. +// +// Path Processing: Simplifies URL paths for metrics by taking the last segment, +// reducing cardinality of metric labels for better performance. +// +// 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 = func(rt http.RoundTripper) http.RoundTripper { - return instrumented_http.NewTransport(rt, &instrumented_http.Callbacks{ - PathProcessor: func(path string) string { - parts := strings.Split(path, "/") - return parts[len(parts)-1] - }, - }) + return extdnshttp.NewInstrumentedTransport(rt) } + config.Timeout = requestTimeout return config, nil } -// GetRestConfig returns the rest clients config to get automatically -// data if you run inside a cluster or by passing flags. +// GetRestConfig returns the REST client configuration for Kubernetes API access. +// Supports both in-cluster and external cluster configurations. +// +// Configuration Priority: +// 1. If kubeConfig is empty, tries the recommended home file (~/.kube/config) +// 2. If kubeConfig is still empty, uses in-cluster service account +// 3. Otherwise, uses the specified kubeConfig file +// +// API Server Override: The apiServerURL parameter can override the server URL +// from the kubeconfig file, useful for proxy scenarios or custom endpoints. func GetRestConfig(kubeConfig, apiServerURL string) (*rest.Config, error) { if kubeConfig == "" { if _, err := os.Stat(clientcmd.RecommendedHomeFile); err == nil { diff --git a/source/store_test.go b/source/store_test.go index c245b0efa..3b9c38498 100644 --- a/source/store_test.go +++ b/source/store_test.go @@ -27,6 +27,7 @@ import ( "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" @@ -239,3 +240,36 @@ func (suite *ByNamesTestSuite) TestDynamicKubernetesClientFails() { 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) CloudFoundryClient(string, string, string) (*cfclient.Client, 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 TestBuildWithConfig_InvalidSource(t *testing.T) { + ctx := context.Background() + 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) + } +} diff --git a/source/targetfiltersource_test.go b/source/targetfiltersource_test.go deleted file mode 100644 index b1f6fc718..000000000 --- a/source/targetfiltersource_test.go +++ /dev/null @@ -1,146 +0,0 @@ -/* -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/require" - "golang.org/x/net/context" - - "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] -} - -// echoSource is a Source that returns the endpoints passed in on creation. -type echoSource struct { - endpoints []*endpoint.Endpoint -} - -func (e *echoSource) AddEventHandler(ctx context.Context, handler func()) { -} - -// Endpoints returns all of the endpoints passed in on creation -func (e *echoSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { - return e.endpoints, nil -} - -// NewEchoSource creates a new echoSource. -func NewEchoSource(endpoints []*endpoint.Endpoint) Source { - return &echoSource{endpoints: endpoints} -} - -func TestEchoSourceReturnGivenSources(t *testing.T) { - startEndpoints := []*endpoint.Endpoint{{ - DNSName: "foo.bar.com", - RecordType: "A", - Targets: endpoint.Targets{"1.2.3.4"}, - RecordTTL: endpoint.TTL(300), - Labels: endpoint.Labels{}, - }} - e := NewEchoSource(startEndpoints) - - endpoints, err := e.Endpoints(context.Background()) - if err != nil { - t.Errorf("Expected no error but got %s", err.Error()) - } - - for i, endpoint := range endpoints { - if endpoint != startEndpoints[i] { - t.Errorf("Expected %s but got %s", startEndpoints[i], endpoint) - } - } -} - -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(t *testing.T) { - var _ 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", "A", "1.2.3.4"), - endpoint.NewEndpoint("foo", "A", "1.2.3.5"), - endpoint.NewEndpoint("foo", "A", "1.2.3.6"), - endpoint.NewEndpoint("foo", "A", "1.3.4.5"), - endpoint.NewEndpoint("foo", "A", "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", "A", "10.0.0.1"), - endpoint.NewEndpoint("foo", "A", "8.8.8.8")}, - expected: []*endpoint.Endpoint{endpoint.NewEndpoint("foo", "A", "8.8.8.8")}, - }, - { - title: "filter only internal", - filters: NewMockTargetNetFilter([]string{"10.0.0.1"}), - endpoints: []*endpoint.Endpoint{ - endpoint.NewEndpoint("foo", "A", "10.0.0.1"), - endpoint.NewEndpoint("foo", "A", "8.8.8.8")}, - expected: []*endpoint.Endpoint{endpoint.NewEndpoint("foo", "A", "10.0.0.1")}, - }, - } - for _, tt := range tests { - - t.Run(tt.title, func(t *testing.T) { - t.Parallel() - - echo := NewEchoSource(tt.endpoints) - src := NewTargetFilterSource(echo, tt.filters) - - endpoints, err := src.Endpoints(context.Background()) - require.NoError(t, err, "failed to get Endpoints") - validateEndpoints(t, endpoints, tt.expected) - }) - } -} diff --git a/source/traefik_proxy.go b/source/traefik_proxy.go index 792efec24..710a8b626 100644 --- a/source/traefik_proxy.go +++ b/source/traefik_proxy.go @@ -100,7 +100,8 @@ func NewTraefikSource( dynamicKubeClient dynamic.Interface, kubeClient kubernetes.Interface, namespace, annotationFilter string, - ignoreHostnameAnnotation, disableLegacy, disableNew bool) (Source, error) { + ignoreHostnameAnnotation, enableLegacy, disableNew bool, +) (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, namespace, nil) @@ -128,7 +129,7 @@ func NewTraefikSource( }, ) } - if !disableLegacy { + if enableLegacy { oldIngressRouteInformer = informerFactory.ForResource(oldIngressRouteGVR) oldIngressRouteTcpInformer = informerFactory.ForResource(oldIngressRouteTCPGVR) oldIngressRouteUdpInformer = informerFactory.ForResource(oldIngressRouteUDPGVR) @@ -232,7 +233,7 @@ func (ts *traefikSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, err // ingressRouteEndpoints extracts endpoints from all IngressRoute objects func (ts *traefikSource) ingressRouteEndpoints() ([]*endpoint.Endpoint, error) { - return extractEndpoints[IngressRoute]( + return extractEndpoints( ts.ingressRouteInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRoute, error) { @@ -297,7 +298,7 @@ func (ts *traefikSource) ingressRouteTCPEndpoints() ([]*endpoint.Endpoint, error // ingressRouteUDPEndpoints extracts endpoints from all IngressRouteUDP objects func (ts *traefikSource) ingressRouteUDPEndpoints() ([]*endpoint.Endpoint, error) { - return extractEndpoints[IngressRouteUDP]( + return extractEndpoints( ts.ingressRouteUdpInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRouteUDP, error) { @@ -311,7 +312,7 @@ func (ts *traefikSource) ingressRouteUDPEndpoints() ([]*endpoint.Endpoint, error // oldIngressRouteEndpoints extracts endpoints from all IngressRoute objects func (ts *traefikSource) oldIngressRouteEndpoints() ([]*endpoint.Endpoint, error) { - return extractEndpoints[IngressRoute]( + return extractEndpoints( ts.oldIngressRouteInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRoute, error) { @@ -327,7 +328,7 @@ func (ts *traefikSource) oldIngressRouteEndpoints() ([]*endpoint.Endpoint, error // oldIngressRouteTCPEndpoints extracts endpoints from all IngressRouteTCP objects func (ts *traefikSource) oldIngressRouteTCPEndpoints() ([]*endpoint.Endpoint, error) { - return extractEndpoints[IngressRouteTCP]( + return extractEndpoints( ts.oldIngressRouteTcpInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRouteTCP, error) { @@ -341,7 +342,7 @@ func (ts *traefikSource) oldIngressRouteTCPEndpoints() ([]*endpoint.Endpoint, er // oldIngressRouteUDPEndpoints extracts endpoints from all IngressRouteUDP objects func (ts *traefikSource) oldIngressRouteUDPEndpoints() ([]*endpoint.Endpoint, error) { - return extractEndpoints[IngressRouteUDP]( + return extractEndpoints( ts.oldIngressRouteUdpInformer.Lister(), ts.namespace, func(u *unstructured.Unstructured) (*IngressRouteUDP, error) { @@ -387,7 +388,7 @@ func (ts *traefikSource) endpointsFromIngressRoute(ingressRoute *IngressRoute, t if !ts.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } @@ -398,7 +399,7 @@ func (ts *traefikSource) endpointsFromIngressRoute(ingressRoute *IngressRoute, t // Checking for host = * is required, as Host(`*`) can be set if host != "*" && host != "" { - endpoints = append(endpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } } @@ -420,7 +421,7 @@ func (ts *traefikSource) endpointsFromIngressRouteTCP(ingressRoute *IngressRoute if !ts.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } @@ -431,7 +432,7 @@ func (ts *traefikSource) endpointsFromIngressRouteTCP(ingressRoute *IngressRoute // Checking for host = * is required, as HostSNI(`*`) can be set // in the case of TLS passthrough if host != "*" && host != "" { - endpoints = append(endpoints, endpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(host, targets, ttl, providerSpecific, setIdentifier, resource)...) } } } @@ -453,7 +454,7 @@ func (ts *traefikSource) endpointsFromIngressRouteUDP(ingressRoute *IngressRoute if !ts.ignoreHostnameAnnotation { hostnameList := annotations.HostnamesFromAnnotations(ingressRoute.Annotations) for _, hostname := range hostnameList { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) + endpoints = append(endpoints, EndpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier, resource)...) } } @@ -907,11 +908,7 @@ func extractEndpoints[T any]( // 4. Iterates through the resources and matches their annotations against the selector. // 5. Returns the filtered list of resources or an error if any step fails. func filterResourcesByAnnotations[T any](resources []*T, annotationFilter string, getAnnotations func(*T) map[string]string) ([]*T, error) { - labelSelector, err := metav1.ParseToLabelSelector(annotationFilter) - if err != nil { - return nil, err - } - selector, err := metav1.LabelSelectorAsSelector(labelSelector) + selector, err := annotations.ParseFilter(annotationFilter) if err != nil { return nil, err } diff --git a/source/traefik_proxy_test.go b/source/traefik_proxy_test.go index 9e345208a..abce58cbc 100644 --- a/source/traefik_proxy_test.go +++ b/source/traefik_proxy_test.go @@ -330,7 +330,6 @@ func TestTraefikProxyIngressRouteEndpoints(t *testing.T) { expected: nil, }, } { - t.Run(ti.title, func(t *testing.T) { t.Parallel() @@ -624,7 +623,6 @@ func TestTraefikProxyIngressRouteTCPEndpoints(t *testing.T) { expected: nil, }, } { - t.Run(ti.title, func(t *testing.T) { t.Parallel() @@ -766,7 +764,6 @@ func TestTraefikProxyIngressRouteUDPEndpoints(t *testing.T) { expected: nil, }, } { - t.Run(ti.title, func(t *testing.T) { t.Parallel() @@ -1096,7 +1093,6 @@ func TestTraefikProxyOldIngressRouteEndpoints(t *testing.T) { expected: nil, }, } { - t.Run(ti.title, func(t *testing.T) { t.Parallel() @@ -1121,7 +1117,7 @@ func TestTraefikProxyOldIngressRouteEndpoints(t *testing.T) { _, err = fakeDynamicClient.Resource(oldIngressRouteGVR).Namespace(defaultTraefikNamespace).Create(context.Background(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) - source, err := NewTraefikSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultTraefikNamespace, "kubernetes.io/ingress.class=traefik", ti.ignoreHostnameAnnotation, false, false) + source, err := NewTraefikSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultTraefikNamespace, "kubernetes.io/ingress.class=traefik", ti.ignoreHostnameAnnotation, true, false) assert.NoError(t, err) assert.NotNil(t, source) @@ -1390,7 +1386,6 @@ func TestTraefikProxyOldIngressRouteTCPEndpoints(t *testing.T) { expected: nil, }, } { - t.Run(ti.title, func(t *testing.T) { t.Parallel() @@ -1415,7 +1410,7 @@ func TestTraefikProxyOldIngressRouteTCPEndpoints(t *testing.T) { _, err = fakeDynamicClient.Resource(oldIngressRouteTCPGVR).Namespace(defaultTraefikNamespace).Create(context.Background(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) - source, err := NewTraefikSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultTraefikNamespace, "kubernetes.io/ingress.class=traefik", ti.ignoreHostnameAnnotation, false, false) + source, err := NewTraefikSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultTraefikNamespace, "kubernetes.io/ingress.class=traefik", ti.ignoreHostnameAnnotation, true, false) assert.NoError(t, err) assert.NotNil(t, source) @@ -1532,7 +1527,6 @@ func TestTraefikProxyOldIngressRouteUDPEndpoints(t *testing.T) { expected: nil, }, } { - t.Run(ti.title, func(t *testing.T) { t.Parallel() @@ -1557,7 +1551,7 @@ func TestTraefikProxyOldIngressRouteUDPEndpoints(t *testing.T) { _, err = fakeDynamicClient.Resource(oldIngressRouteUDPGVR).Namespace(defaultTraefikNamespace).Create(context.Background(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) - source, err := NewTraefikSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultTraefikNamespace, "kubernetes.io/ingress.class=traefik", ti.ignoreHostnameAnnotation, false, false) + source, err := NewTraefikSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultTraefikNamespace, "kubernetes.io/ingress.class=traefik", ti.ignoreHostnameAnnotation, true, false) assert.NoError(t, err) assert.NotNil(t, source) @@ -1574,7 +1568,7 @@ func TestTraefikProxyOldIngressRouteUDPEndpoints(t *testing.T) { } } -func TestTraefikAPIGroupDisableFlags(t *testing.T) { +func TestTraefikAPIGroupFlags(t *testing.T) { t.Parallel() for _, ti := range []struct { @@ -1582,7 +1576,7 @@ func TestTraefikAPIGroupDisableFlags(t *testing.T) { ingressRoute IngressRoute gvr schema.GroupVersionResource ignoreHostnameAnnotation bool - disableLegacy bool + enableLegacy bool disableNew bool expected []*endpoint.Endpoint }{ @@ -1603,9 +1597,9 @@ func TestTraefikAPIGroupDisableFlags(t *testing.T) { }, }, }, - gvr: oldIngressRouteGVR, - disableLegacy: false, - disableNew: false, + gvr: oldIngressRouteGVR, + enableLegacy: true, + disableNew: false, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", @@ -1636,9 +1630,9 @@ func TestTraefikAPIGroupDisableFlags(t *testing.T) { }, }, }, - gvr: oldIngressRouteGVR, - disableLegacy: true, - disableNew: false, + gvr: oldIngressRouteGVR, + enableLegacy: false, + disableNew: false, }, { title: "IngressRoute.traefik.io with the new API group enabled", @@ -1657,9 +1651,9 @@ func TestTraefikAPIGroupDisableFlags(t *testing.T) { }, }, }, - gvr: ingressRouteGVR, - disableLegacy: false, - disableNew: false, + gvr: ingressRouteGVR, + enableLegacy: true, + disableNew: false, expected: []*endpoint.Endpoint{ { DNSName: "a.example.com", @@ -1690,12 +1684,11 @@ func TestTraefikAPIGroupDisableFlags(t *testing.T) { }, }, }, - gvr: ingressRouteGVR, - disableLegacy: false, - disableNew: true, + gvr: ingressRouteGVR, + enableLegacy: true, + disableNew: true, }, } { - t.Run(ti.title, func(t *testing.T) { t.Parallel() @@ -1720,7 +1713,7 @@ func TestTraefikAPIGroupDisableFlags(t *testing.T) { _, err = fakeDynamicClient.Resource(ti.gvr).Namespace(defaultTraefikNamespace).Create(context.Background(), &ir, metav1.CreateOptions{}) assert.NoError(t, err) - source, err := NewTraefikSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultTraefikNamespace, "kubernetes.io/ingress.class=traefik", ti.ignoreHostnameAnnotation, ti.disableLegacy, ti.disableNew) + source, err := NewTraefikSource(context.TODO(), fakeDynamicClient, fakeKubernetesClient, defaultTraefikNamespace, "kubernetes.io/ingress.class=traefik", ti.ignoreHostnameAnnotation, ti.enableLegacy, ti.disableNew) assert.NoError(t, err) assert.NotNil(t, source) diff --git a/source/utils.go b/source/utils.go index 1023c3442..5858fddb8 100644 --- a/source/utils.go +++ b/source/utils.go @@ -42,7 +42,9 @@ func suitableType(target string) string { // 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) (namespace, name string, err error) { +func ParseIngress(ingress string) (string, string, error) { + var namespace, name string + var err error parts := strings.Split(ingress, "/") if len(parts) == 2 { namespace, name = parts[0], parts[1] @@ -52,7 +54,7 @@ func ParseIngress(ingress string) (namespace, name string, err error) { err = fmt.Errorf("invalid ingress name (name or namespace/name) found %q", ingress) } - return + return namespace, name, err } // MatchesServiceSelector checks if all key-value pairs in the selector map diff --git a/source/dedupsource.go b/source/wrappers/dedupsource.go similarity index 92% rename from source/dedupsource.go rename to source/wrappers/dedupsource.go index 3ac90d5db..665799b44 100644 --- a/source/dedupsource.go +++ b/source/wrappers/dedupsource.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package source +package wrappers import ( "context" @@ -22,16 +22,18 @@ import ( log "github.com/sirupsen/logrus" + "sigs.k8s.io/external-dns/source" + "sigs.k8s.io/external-dns/endpoint" ) // dedupSource is a Source that removes duplicate endpoints from its wrapped source. type dedupSource struct { - source Source + source source.Source } // NewDedupSource creates a new dedupSource wrapping the provided Source. -func NewDedupSource(source Source) Source { +func NewDedupSource(source source.Source) source.Source { return &dedupSource{source: source} } diff --git a/source/dedupsource_test.go b/source/wrappers/dedupsource_test.go similarity index 98% rename from source/dedupsource_test.go rename to source/wrappers/dedupsource_test.go index d387f8819..07a13f40b 100644 --- a/source/dedupsource_test.go +++ b/source/wrappers/dedupsource_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package source +package wrappers import ( "context" @@ -22,10 +22,11 @@ import ( "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 = &dedupSource{} +var _ source.Source = &dedupSource{} func TestDedup(t *testing.T) { t.Run("Endpoints", testDedupEndpoints) diff --git a/source/multisource.go b/source/wrappers/multisource.go similarity index 75% rename from source/multisource.go rename to source/wrappers/multisource.go index 80f5335b8..60c287716 100644 --- a/source/multisource.go +++ b/source/wrappers/multisource.go @@ -14,20 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -package source +package wrappers import ( "context" "strings" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source" log "github.com/sirupsen/logrus" ) // multiSource is a Source that merges the endpoints of its nested Sources. type multiSource struct { - children []Source + children []source.Source defaultTargets []string forceDefaultTargets bool } @@ -48,20 +49,20 @@ func (ms *multiSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, err continue } - for i := range endpoints { - hasSourceTargets := len(endpoints[i].Targets) > 0 + for _, ep := range endpoints { + hasSourceTargets := len(ep.Targets) > 0 if ms.forceDefaultTargets || !hasSourceTargets { - eps := endpointsForHostname(endpoints[i].DNSName, ms.defaultTargets, endpoints[i].RecordTTL, endpoints[i].ProviderSpecific, endpoints[i].SetIdentifier, "") - for _, ep := range eps { - ep.Labels = endpoints[i].Labels + eps := source.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.", endpoints[i].DNSName, endpoints[i].RecordType, strings.Join(ms.defaultTargets, ", ")) - result = append(result, endpoints[i]) + 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) } } @@ -75,6 +76,6 @@ func (ms *multiSource) AddEventHandler(ctx context.Context, handler func()) { } // NewMultiSource creates a new multiSource. -func NewMultiSource(children []Source, defaultTargets []string, forceDefaultTargets bool) Source { +func NewMultiSource(children []source.Source, defaultTargets []string, forceDefaultTargets bool) source.Source { return &multiSource{children: children, defaultTargets: defaultTargets, forceDefaultTargets: forceDefaultTargets} } diff --git a/source/multisource_test.go b/source/wrappers/multisource_test.go similarity index 94% rename from source/multisource_test.go rename to source/wrappers/multisource_test.go index 8386c75b4..06626a08c 100644 --- a/source/multisource_test.go +++ b/source/wrappers/multisource_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package source +package wrappers import ( "context" @@ -23,6 +23,7 @@ import ( "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" @@ -39,7 +40,7 @@ func TestMultiSource(t *testing.T) { // testMultiSourceImplementsSource tests that multiSource is a valid Source. func testMultiSourceImplementsSource(t *testing.T) { - assert.Implements(t, (*Source)(nil), new(multiSource)) + assert.Implements(t, (*source.Source)(nil), new(multiSource)) } // testMultiSourceEndpoints tests merged endpoints from children are returned. @@ -78,7 +79,7 @@ func testMultiSourceEndpoints(t *testing.T) { t.Parallel() // Prepare the nested mock sources. - sources := make([]Source, 0, len(tc.nestedEndpoints)) + sources := make([]source.Source, 0, len(tc.nestedEndpoints)) // Populate the nested mock sources. for _, endpoints := range tc.nestedEndpoints { @@ -116,7 +117,7 @@ func testMultiSourceEndpointsWithError(t *testing.T) { src.On("Endpoints").Return(nil, errSomeError) // Create our object under test and get the endpoints. - source := NewMultiSource([]Source{src}, nil, false) + source := NewMultiSource([]source.Source{src}, nil, false) // Get endpoints from our source. _, err := source.Endpoints(context.Background()) @@ -155,7 +156,7 @@ func testMultiSourceEndpointsDefaultTargets(t *testing.T) { src.On("Endpoints").Return(sourceEndpoints, nil) // Test with forceDefaultTargets=false (default behavior) - source := NewMultiSource([]Source{src}, defaultTargets, false) + source := NewMultiSource([]source.Source{src}, defaultTargets, false) endpoints, err := source.Endpoints(context.Background()) require.NoError(t, err) @@ -185,7 +186,7 @@ func testMultiSourceEndpointsDefaultTargets(t *testing.T) { src.On("Endpoints").Return(sourceEndpoints, nil) // Test with forceDefaultTargets=false (default behavior) - source := NewMultiSource([]Source{src}, defaultTargets, false) + source := NewMultiSource([]source.Source{src}, defaultTargets, false) endpoints, err := source.Endpoints(context.Background()) require.NoError(t, err) @@ -223,7 +224,7 @@ func testMultiSourceEndpointsDefaultTargets(t *testing.T) { src.On("Endpoints").Return(sourceEndpoints, nil) // Test with forceDefaultTargets=true (legacy behavior) - source := NewMultiSource([]Source{src}, defaultTargets, true) + source := NewMultiSource([]source.Source{src}, defaultTargets, true) endpoints, err := source.Endpoints(context.Background()) require.NoError(t, err) @@ -258,7 +259,7 @@ func testMultiSourceEndpointsDefaultTargets(t *testing.T) { src.On("Endpoints").Return(sourceEndpoints, nil) // Test with forceDefaultTargets=true - source := NewMultiSource([]Source{src}, defaultTargets, true) + source := NewMultiSource([]source.Source{src}, defaultTargets, true) endpoints, err := source.Endpoints(context.Background()) require.NoError(t, err) diff --git a/source/nat64source.go b/source/wrappers/nat64source.go similarity index 94% rename from source/nat64source.go rename to source/wrappers/nat64source.go index 79bcf01d8..383bdde5c 100644 --- a/source/nat64source.go +++ b/source/wrappers/nat64source.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package source +package wrappers import ( "context" @@ -22,16 +22,17 @@ import ( "net/netip" "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/source" ) // nat64Source is a Source that adds A endpoints for AAAA records including an NAT64 address. type nat64Source struct { - source Source + source source.Source nat64Prefixes []string } // NewNAT64Source creates a new nat64Source wrapping the provided Source. -func NewNAT64Source(source Source, nat64Prefixes []string) Source { +func NewNAT64Source(source source.Source, nat64Prefixes []string) source.Source { return &nat64Source{source: source, nat64Prefixes: nat64Prefixes} } diff --git a/source/nat64source_test.go b/source/wrappers/nat64source_test.go similarity index 97% rename from source/nat64source_test.go rename to source/wrappers/nat64source_test.go index d7a6a0a33..3a0d15f12 100644 --- a/source/nat64source_test.go +++ b/source/wrappers/nat64source_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package source +package wrappers import ( "context" @@ -22,10 +22,11 @@ import ( "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 = &nat64Source{} +var _ source.Source = &nat64Source{} func TestNAT64Source(t *testing.T) { t.Run("Endpoints", testNat64Source) diff --git a/source/wrappers/source_test.go b/source/wrappers/source_test.go new file mode 100644 index 000000000..896e98af0 --- /dev/null +++ b/source/wrappers/source_test.go @@ -0,0 +1,102 @@ +/* +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 { + 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) + } +} diff --git a/source/targetfiltersource.go b/source/wrappers/targetfiltersource.go similarity index 80% rename from source/targetfiltersource.go rename to source/wrappers/targetfiltersource.go index e20273606..afc654d90 100644 --- a/source/targetfiltersource.go +++ b/source/wrappers/targetfiltersource.go @@ -14,39 +14,45 @@ See the License for the specific language governing permissions and limitations under the License. */ -package source +package wrappers import ( "context" log "github.com/sirupsen/logrus" + "sigs.k8s.io/external-dns/source" + "sigs.k8s.io/external-dns/endpoint" ) // targetFilterSource is a Source that removes endpoints matching the target filter from its wrapped source. type targetFilterSource struct { - source Source + source source.Source targetFilter endpoint.TargetFilterInterface } // NewTargetFilterSource creates a new targetFilterSource wrapping the provided Source. -func NewTargetFilterSource(source Source, targetFilter endpoint.TargetFilterInterface) 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) { - result := []*endpoint.Endpoint{} - 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 := []string{} + filteredTargets := make([]string, 0, len(ep.Targets)) for _, t := range ep.Targets { if ms.targetFilter.Match(t) { @@ -69,5 +75,7 @@ func (ms *targetFilterSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoi } func (ms *targetFilterSource) AddEventHandler(ctx context.Context, handler func()) { - ms.source.AddEventHandler(ctx, handler) + if ms.targetFilter.IsEnabled() { + ms.source.AddEventHandler(ctx, handler) + } } diff --git a/source/wrappers/targetfiltersource_test.go b/source/wrappers/targetfiltersource_test.go new file mode 100644 index 000000000..e0e01d745 --- /dev/null +++ b/source/wrappers/targetfiltersource_test.go @@ -0,0 +1,270 @@ +/* +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/mock" + "github.com/stretchr/testify/require" + "golang.org/x/net/context" + "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 +} + +// echoSource is a Source that returns the endpoints passed in on creation. +type echoSource struct { + mock.Mock + endpoints []*endpoint.Endpoint +} + +func (e *echoSource) AddEventHandler(ctx context.Context, handler func()) { + e.Called(ctx) +} + +// Endpoints returns all the endpoints passed in on creation +func (e *echoSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) { + return e.endpoints, nil +} + +// NewEchoSource creates a new echoSource. +func NewEchoSource(endpoints []*endpoint.Endpoint) source.Source { + return &echoSource{endpoints: endpoints} +} + +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 := NewEchoSource(startEndpoints) + + endpoints, err := e.Endpoints(context.Background()) + 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(t *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 := NewEchoSource(tt.endpoints) + src := NewTargetFilterSource(echo, tt.filters) + + endpoints, err := src.Endpoints(context.Background()) + 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 := NewEchoSource(tt.endpoints) + src := NewTargetFilterSource(echo, tt.filters) + + endpoints, err := src.Endpoints(context.Background()) + 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 not add event handler if target filter is disabled", + filters: endpoint.NewTargetNetFilterWithExclusions([]string{}, []string{}), + times: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + echo := NewEchoSource([]*endpoint.Endpoint{}) + + m := echo.(*echoSource) + m.On("AddEventHandler", t.Context()).Return() + + src := NewTargetFilterSource(echo, tt.filters) + src.AddEventHandler(t.Context(), func() {}) + + m.AssertNumberOfCalls(t, "AddEventHandler", tt.times) + }) + } +}