Merge branch 'master' of https://github.com/kubernetes-sigs/external-dns into fix/issue-4914

This commit is contained in:
u-kai 2025-07-31 07:49:30 +09:00
commit e67d7bfb80
155 changed files with 6399 additions and 1735 deletions

View File

@ -7,8 +7,10 @@ assignees: ''
---
<!-- Please use this template while reporting a bug and provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. Thanks!
<!--
Please use this template while reporting a bug and provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. Thanks!
Make sure to validate the behavior against latest release https://github.com/kubernetes-sigs/external-dns/releases as we don't support past versions.
-->
**What happened**:
@ -17,6 +19,10 @@ assignees: ''
**How to reproduce it (as minimally and precisely as possible)**:
<!--
Please provide as much detail as possible, including Kubernetes manifests with spec.status, ExternalDNS arguments, and logs. A bug that cannot be reproduced won't be fixed.
-->
**Anything else we need to know?**:
**Environment**:

View File

@ -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 }}

View File

@ -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'

View File

@ -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

View File

@ -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)

4
apis/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- apis

View File

@ -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

View File

@ -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

View File

@ -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. |

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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

View File

@ -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"
]

View File

@ -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`.

4
controller/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- controller

View File

@ -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.",

View File

@ -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
}

View File

@ -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.

View File

@ -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)
}
```

View File

@ -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) |

View File

@ -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 |

View File

@ -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 |

View File

@ -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 = "-_"
}
```

View File

@ -5,7 +5,7 @@ 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 |
| connector | | | |
| contour-httpproxy | HttpProxy.projectcontour.io | Yes | |
@ -24,7 +24,7 @@ ExternalDNS watches the specified sources for hostname information and uses it t
| [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 | | |
| [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 | |

View File

@ -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"

View File

@ -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`.

View File

@ -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

View File

@ -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

View File

@ -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

215
docs/tutorials/myra.md Normal file
View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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{}

View File

@ -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)
})
}
}

View File

@ -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)
for _, filter := range filters {
if filter.Contains(ip) {
return true
}

View File

@ -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())
}
}

100
go.mod
View File

@ -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

210
go.sum
View File

@ -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=

4
internal/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- internal

View File

@ -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) {

29
internal/idna/idna.go Normal file
View File

@ -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),
)
)

View File

@ -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)
})
}
}

4
kustomize/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- kustomize

4
pkg/apis/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- apis

View File

@ -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)

97
pkg/http/http.go Normal file
View File

@ -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]
}

81
pkg/http/http_test.go Normal file
View File

@ -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))
})
}
}

4
pkg/metrics/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- metrics

View File

@ -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:

View File

@ -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 }),
}
}

View File

@ -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\"")
}

4
pkg/rfc2317/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- rfc2317

View File

@ -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}

4
pkg/tlsutils/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- tls

4
plan/OWNERS Normal file
View File

@ -0,0 +1,4 @@
# See the OWNERS docs at https://go.k8s.io/owners
labels:
- plan

View File

@ -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 {
if slices.Contains(excludeRecords, record) {
return false
}
}
for _, r := range managedRecords {
if record == r {
return true
}
}
return false
return slices.Contains(managedRecords, record)
}

View File

@ -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 {
tt.Run(r.dnsName, func(t *testing.T) {
gotName := normalizeDNSName(r.dnsName)
assert.Equal(t, r.expect, gotName)
})
}
}

View File

@ -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())

View File

@ -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

View File

@ -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))}

View File

@ -60,12 +60,20 @@ const (
providerSpecificGeolocationContinentCode = "aws/geolocation-continent-code"
providerSpecificGeolocationCountryCode = "aws/geolocation-country-code"
providerSpecificGeolocationSubdivisionCode = "aws/geolocation-subdivision-code"
providerSpecificGeoProximityLocationAWSRegion = "aws/geoproximity-region"
providerSpecificGeoProximityLocationBias = "aws/geoproximity-bias"
providerSpecificGeoProximityLocationCoordinates = "aws/geoproximity-coordinates"
providerSpecificGeoProximityLocationLocalZoneGroup = "aws/geoproximity-local-zone-group"
providerSpecificMultiValueAnswer = "aws/multi-value-answer"
providerSpecificHealthCheckID = "aws/health-check-id"
sameZoneAlias = "same-zone"
// Currently supported up to 10 health checks or hosted zones.
// https://docs.aws.amazon.com/Route53/latest/APIReference/API_ListTagsForResources.html#API_ListTagsForResources_RequestSyntax
batchSize = 10
minLatitude = -90.0
maxLatitude = 90.0
minLongitude = -180.0
maxLongitude = 180.0
)
// see elb: https://docs.aws.amazon.com/general/latest/gr/elb.html
@ -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,7 +734,11 @@ 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 {
if p.dryRun {
log.Debug("Dry run mode, skipping change submission")
continue
}
params := &route53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String(z),
ChangeBatch: &route53types.ChangeBatch{
@ -742,7 +789,6 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
time.Sleep(p.batchChangeInterval)
}
}
}
if failedUpdate {
failedZones = append(failedZones, z)
@ -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;

View File

@ -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")
}

View File

@ -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),
}

View File

@ -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{
return []sdtypes.NamespaceFilter{
{
Name: sdtypes.NamespaceFilterNameType,
Values: []string{string(sdtypes.NamespaceTypeDnsPublic)},
},
}
case sdNamespaceTypePrivate:
return sdtypes.NamespaceFilter{
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

View File

@ -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"])
}

View File

@ -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() {

View File

@ -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 {

View File

@ -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.",

View File

@ -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 {

View File

@ -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,16 +107,16 @@ 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)
@ -120,10 +124,27 @@ type cloudFlareDNS interface {
type zoneService struct {
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
@ -288,6 +321,7 @@ func NewCloudFlareProvider(
// initialize via chosen auth method and returns new API object
var (
config *cloudflare.API
configV4 *cloudflarev4.Client
err error
)
if 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{}

View File

@ -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,
})
}
}

View File

@ -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,
},
},

View File

@ -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
listZonesError error // For v4 ListZones
getZoneError error // For v4 GetZone
dnsRecordsError error
customHostnames map[string][]cloudflare.CustomHostname
regionalHostnames map[string][]cloudflare.RegionalHostname
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")
}

View File

@ -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
}
}
}
}

View File

@ -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)
})
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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",
})

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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{}

View File

@ -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 {

View File

@ -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,

View File

@ -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 == ','
@ -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,30 +285,36 @@ 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"))
}
req, err := http.NewRequestWithContext(ctx, action, apiUrl, nil)
for _, target := range ep.Targets {
if p.cfg.DryRun {
log.Infof("DRY RUN: %s %s IN %s -> %s", action, ep.DNSName, ep.RecordType, target)
continue
}
log.Infof("%s %s IN %s -> %s", action, ep.DNSName, ep.RecordType, target)
targetApiUrl := apiUrl
switch ep.RecordType {
case endpoint.RecordTypeA, endpoint.RecordTypeAAAA:
targetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf("%s %s", target, ep.DNSName))
case endpoint.RecordTypeCNAME:
if ep.RecordTTL.IsConfigured() {
targetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf("%s,%s,%d", ep.DNSName, target, ep.RecordTTL))
} else {
targetApiUrl = p.generateApiUrl(targetApiUrl, fmt.Sprintf("%s,%s", ep.DNSName, target))
}
}
req, err := http.NewRequestWithContext(ctx, action, targetApiUrl, nil)
if err != nil {
return err
}
@ -304,6 +323,7 @@ func (p *piholeClientV6) apply(ctx context.Context, action string, ep *endpoint.
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 {

View File

@ -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])
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)
}
if rec.Targets[0] != expected[idx][1] {
t.Error("Got invalid target:", rec.Targets[0], "expected:", expected[idx][1])
}
}
// 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])
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)
}
if rec.Targets[0] != expected[idx][1] {
t.Error("Got invalid target:", rec.Targets[0], "expected:", expected[idx][1])
}
}
// 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])
expectedMap = make(map[string]*endpoint.Endpoint)
for _, ep := range expected {
expectedMap[ep.DNSName] = ep
}
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])
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",

View File

@ -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"
@ -33,6 +36,7 @@ var ErrNoPiholeServer = errors.New("no pihole server found in the environment or
type PiholeProvider struct {
provider.BaseProvider
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,16 +129,25 @@ 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 the API version is 6, we need to handle multiple targets for the same DNS name.
if p.apiVersion == "6" {
if cmp.Diff(ep.Targets, newRecord.Targets) == "" {
delete(updateNew, key)
continue
}
} else {
// For API version <= 5, we only check the first target.
if newRecord.Targets[0] == ep.Targets[0] {
delete(updateNew, key)
continue
}
}
if err := p.api.deleteRecord(ctx, ep); err != nil {
return err
}
}
}
// Handle pure creates before applying new updated state.
for _, ep := range changes.Create {

View File

@ -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)
}
}
@ -83,6 +84,7 @@ func TestErrorHandling(t *testing.T) {
requests := requestTrackerV6{}
p := &PiholeProvider{
api: &testPiholeClientV6{endpoints: make([]*endpoint.Endpoint, 0), requests: &requests},
apiVersion: "6",
}
p.api.(*testPiholeClientV6).trigger = "AERROR"
@ -122,6 +124,7 @@ func TestProviderV6(t *testing.T) {
requests := requestTrackerV6{}
p := &PiholeProvider{
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{

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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")
}

View File

@ -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",

Some files were not shown because too many files have changed in this diff Show More