Merge branch 'kubernetes-sigs:master' into fix-events

This commit is contained in:
Ivan Ka 2025-08-01 08:50:19 +01:00 committed by GitHub
commit 03f7c8d92e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1450 additions and 324 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.4
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

@ -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.
@ -104,6 +105,7 @@ from the usage of any externally developed webhook.
| 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 |
@ -203,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)

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

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

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

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

70
go.mod
View File

@ -4,7 +4,7 @@ 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.1
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2
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
@ -13,23 +13,23 @@ require (
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.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/aws/aws-sdk-go-v2 v1.37.1
github.com/aws/aws-sdk-go-v2/config v1.30.2
github.com/aws/aws-sdk-go-v2/credentials v1.18.2
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.1
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.45.1
github.com/aws/aws-sdk-go-v2/service/route53 v1.54.1
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.36.1
github.com/aws/aws-sdk-go-v2/service/sts v1.35.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.3
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.159.0
github.com/digitalocean/godo v1.161.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
@ -38,35 +38,35 @@ 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/linode/linodego v1.53.0
github.com/linode/linodego v1.54.0
github.com/maxatome/go-testdeep v1.14.0
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.96.0
github.com/oracle/oci-go-sdk/v65 v65.97.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_golang v1.23.0
github.com/prometheus/client_model v0.6.2
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.3
go.etcd.io/etcd/client/v3 v3.6.3
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.42.0
golang.org/x/oauth2 v0.30.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.243.0
google.golang.org/api v0.244.0
gopkg.in/ns1/ns1-go.v2 v2.14.4
istio.io/api v1.26.2
istio.io/client-go v1.26.2
istio.io/api v1.26.3
istio.io/client-go v1.26.3
k8s.io/api v0.33.3
k8s.io/apimachinery v0.33.3
k8s.io/client-go v0.33.3
@ -80,22 +80,22 @@ require (
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.73 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // 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.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/feature/ec2/imds v1.18.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // 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.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/aws/aws-sdk-go-v2/service/dynamodbstreams v1.27.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.26.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.31.1 // indirect
github.com/aws/smithy-go v1.22.5 // indirect
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@ -154,7 +154,7 @@ 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/procfs v0.15.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/schollz/progressbar/v3 v3.8.6 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
@ -170,7 +170,7 @@ require (
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.3 // 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
@ -185,8 +185,8 @@ require (
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/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect

140
go.sum
View File

@ -16,14 +16,14 @@ github.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gE
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.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/azcore v1.18.2 h1:Hr5FTipp7SL07o2FvoVOX9HRiRH3CR3Mj8pxqCcdD5A=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2/go.mod h1:QyVsSSN64v5TGltphKLQ2sQxe4OBQg0J1eKRcVBnfgE=
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=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 h1:lpOxwrQ919lCZoNCd69rVt8u1eLZuMORrGXqy8sNf3c=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
@ -114,44 +114,44 @@ 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.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 v1.37.1 h1:SMUxeNz3Z6nqGsXv0JuJXc8w5YMtrQMuIBmDx//bBDY=
github.com/aws/aws-sdk-go-v2 v1.37.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/config v1.30.2 h1:YE1BmSc4fFYqFgN1mN8uzrtc7R9x+7oSWeX8ckoltAw=
github.com/aws/aws-sdk-go-v2/config v1.30.2/go.mod h1:UNrLGZ6jfAVjgVJpkIxjLufRJqTXCVYOpkeVf83kwBo=
github.com/aws/aws-sdk-go-v2/credentials v1.18.2 h1:mfm0GKY/PHLhs7KO0sUaOtFnIQ15Qqxt+wXbO/5fIfs=
github.com/aws/aws-sdk-go-v2/credentials v1.18.2/go.mod h1:v0SdJX6ayPeZFQxgXUKw5RhLpAoZUuynxWDfh8+Eknc=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.1 h1:1ToPL5M0nYwkIOTb9r+ION0ZZe9xemRe1mRMWMw5ihs=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.1/go.mod h1:dDdNpGWZdj4AxADkfM1IG1IutBmSJM7zURhUNOVv/lE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.1 h1:owmNBboeA0kHKDcdF8KiSXmrIuXZustfMGGytv6OMkM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.1/go.mod h1:Bg1miN59SGxrZqlP8vJZSmXW+1N8Y1MjQDq1OfuNod8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.1 h1:ksZXBYv80EFTcgc8OJO48aQ8XDWXIQL7gGasPeCoTzI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.1/go.mod h1:HSksQyyJETVZS7uM54cir0IgxttTD+8aEoJMPGepHBI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.1 h1:+dn/xF/05utS7tUhjIcndbuaPjfll2LhbH1cCDGLYUQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.1/go.mod h1:hyAGz30LHdm5KBZDI58MXx5lDVZ5CUfvfTZvMu4HCZo=
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.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.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/aws/aws-sdk-go-v2/service/dynamodb v1.45.1 h1:gFD9BLrXox2Q5zxFwyD2OnGb40YYofQ/anaGxVP848Q=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.45.1/go.mod h1:J+qJkxNypYjDcwXldBH+ox2T7OshtP6LOq5VhU0v6hg=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.27.1 h1:H4W48E0/zjiHLlL59/Y0DpaB+krXsuarjwrquCwMtT4=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.27.1/go.mod h1:nGsqtVMMjTeFot6U+rLj+mpOcZybPoxyQPMKY4GHwQo=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.1 h1:/E4JUPMI8LRX2XpXsbmKN42l1lZPoLjGJ/Kun97pLc0=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.1/go.mod h1:qgbd/t8S8y5e87KPQ4kC0kyxZ0K6nC1QiDtFMoxlsOo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.1 h1:ky79ysLMxhwk5rxJtS+ILd3Mc8kC5fhsLBrP27r6h4I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.1/go.mod h1:+2MmkvFvPYM1vsozBWduoLJUi5maxFk5B7KJFECujhY=
github.com/aws/aws-sdk-go-v2/service/route53 v1.54.1 h1:DvwcqU6ec5NNCACSSEYKuTg9J3PDFFlngkwV0k7wvaI=
github.com/aws/aws-sdk-go-v2/service/route53 v1.54.1/go.mod h1:POH50FEbIpazXJUVj2hbpJT819o2UF547G+BJBM7HQM=
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.36.1 h1:EqupyVMtt84ZljchBzq+X+pPwuYhUT7dzfBoDqC0DB4=
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.36.1/go.mod h1:HrkmhW8FU7GObElHC6Lm3sosolbig21w00VOm77Vsss=
github.com/aws/aws-sdk-go-v2/service/sso v1.26.1 h1:uWaz3DoNK9MNhm7i6UGxqufwu3BEuJZm72WlpGwyVtY=
github.com/aws/aws-sdk-go-v2/service/sso v1.26.1/go.mod h1:ILpVNjL0BO+Z3Mm0SbEeUoYS9e0eJWV1BxNppp0fcb8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.31.1 h1:XdG6/o1/ZDmn3wJU5SRAejHaWgKS4zHv0jBamuKuS2k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.31.1/go.mod h1:oiotGTKadCOCl3vg/tYh4k45JlDF81Ka8rdumNhEnIQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.35.1 h1:iF4Xxkc0H9c/K2dS0zZw3SCkj0Z7n6AMnUiiyoJND+I=
github.com/aws/aws-sdk-go-v2/service/sts v1.35.1/go.mod h1:0bxIatfN0aLq4mjoLDeBpOjOke68OsFlXPDFJ7V0MYw=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20160804104726-4c0e84591b9a/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@ -172,16 +172,16 @@ 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.3 h1:AgTJa2C8Q6q+vFfsaa41vVZBQXVOiUdb/JbWpNuDlc8=
github.com/civo/civogo v0.6.3/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=
@ -252,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.159.0 h1:GQLfVueriDHYpwLzDcbydHs6nBvQBO8/r8r9imPC434=
github.com/digitalocean/godo v1.159.0/go.mod h1:tYeiWY5ZXVpU48YaFv0M5irUFHXGorZpDNm7zzdWMzM=
github.com/digitalocean/godo v1.161.0 h1:Q/3ImcotZp0GV9FY/dnLj9TmfOd+a7ZN/UNuhgDHI/Q=
github.com/digitalocean/godo v1.161.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=
@ -676,8 +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/linode/linodego v1.53.0 h1:UWr7bUUVMtcfsuapC+6blm6+jJLPd7Tf9MZUpdOERnI=
github.com/linode/linodego v1.53.0/go.mod h1:bI949fZaVchjWyKIA08hNyvAcV6BAS+PM2op3p7PAWA=
github.com/linode/linodego v1.54.0 h1:29vTV5YjqjjwPxWLE8Qp1zgDDXM5ifAQ2T6azAYsj/w=
github.com/linode/linodego v1.54.0/go.mod h1:VHlFAbhj18634Cd7B7L5D723kFKFQMOxzIutSMcWsB4=
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=
@ -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.96.0 h1:ew0WavsB6N/I6etYCC160cD5qDXbek/1xZgujqTzork=
github.com/oracle/oci-go-sdk/v65 v65.96.0/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA=
github.com/oracle/oci-go-sdk/v65 v65.97.0 h1:QyOOg/qCIY6vBSD+GHUfEQaJk9Wbl8a6VESkXfZ0fYo=
github.com/oracle/oci-go-sdk/v65 v65.97.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=
@ -864,8 +864,8 @@ github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDf
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og=
github.com/prometheus/client_golang v1.6.0/go.mod h1:ZLOG9ck3JLRdB5MgO8f+lLTe83AXG6ro35rLTxvnIl4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
@ -892,8 +892,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
@ -1050,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.3 h1:4Lftl1e6VzBsj5HPhLu8GGybjeT5qg9mug70RxTHmQQ=
go.etcd.io/etcd/api/v3 v3.6.3/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
go.etcd.io/etcd/client/pkg/v3 v3.6.3 h1:1yE8p3PFZ+CWaVyTZk+6ngSyMK8TaG2589W3KGm22ao=
go.etcd.io/etcd/client/pkg/v3 v3.6.3/go.mod h1:sbdzr2cl3HzVmxNw//PH7aLGVtY4QySjQFuaCgcRFAI=
go.etcd.io/etcd/client/v3 v3.6.3 h1:yKpdrcVK6jTfr/VuVuH5VesaLmUi8PLI9eXHTy5kpTM=
go.etcd.io/etcd/client/v3 v3.6.3/go.mod h1:zDuGaiUvpECwqClZCUkHi6q2XSf2ejPbUB755QLXdL8=
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=
@ -1350,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.243.0 h1:sw+ESIJ4BVnlJcWu9S+p2Z6Qq1PjG77T8IJ1xtp4jZQ=
google.golang.org/api v0.243.0/go.mod h1:GE4QtYfaybx1KmeHMdBnNnyLzBZCVihGBXAmJu/uUr8=
google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
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=
@ -1369,8 +1369,8 @@ google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuO
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/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/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=
@ -1384,8 +1384,8 @@ google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQ
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -1452,10 +1452,10 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
istio.io/api v1.26.2 h1:gLkGSB2nkqA/9u/tE/OMEv+U4Fhci2JZgIqjA0CxMak=
istio.io/api v1.26.2/go.mod h1:DTVGH6CLXj5W8FF9JUD3Tis78iRgT1WeuAnxfTz21Wg=
istio.io/client-go v1.26.2 h1:XWvQzBM69vB2xo1bzrp+CYbE8KlRAPotR4ls5Vv35EM=
istio.io/client-go v1.26.2/go.mod h1:eAImguSJPdaDiSSS2CEsywNHE8WWfqd3WfS18Rj8ynI=
istio.io/api v1.26.3 h1:/TiA7bJi24yBQSgpLy5vHhFkobf4DWS1L+CuUxNk4os=
istio.io/api v1.26.3/go.mod h1:DTVGH6CLXj5W8FF9JUD3Tis78iRgT1WeuAnxfTz21Wg=
istio.io/client-go v1.26.3 h1:ryF4+Nyz5wDO4mVCzXcm2W+fqbnekY88Z36hTcv5fnw=
istio.io/client-go v1.26.3/go.mod h1:u2p5L7UvjNswrrlHZ+QMlUOjERK2sXputywzyNhtTMg=
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=

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

View File

@ -23,9 +23,9 @@ import (
"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.
@ -340,16 +340,10 @@ func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.Ma
return filtered
}
var idnaProfile = idna.New(
idna.MapForLookup(),
idna.Transitional(true),
idna.StrictDomainName(false),
)
// normalizeDNSName converts a DNS name to a canonical form, so that we can use string equality
// it: removes space, get ASCII version of dnsName complient with Section 5 of RFC 5891, ensures there is a trailing dot
func normalizeDNSName(dnsName string) string {
s, err := idnaProfile.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)
}

View File

@ -734,56 +734,59 @@ func (p *AWSProvider) submitChanges(ctx context.Context, changes Route53Changes,
log.Infof("Desired change: %s %s %s", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type)
}
if !p.dryRun {
params := &route53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String(z),
ChangeBatch: &route53types.ChangeBatch{
Changes: b.Route53Changes(),
},
}
if p.dryRun {
log.Debug("Dry run mode, skipping change submission")
continue
}
successfulChanges := 0
params := &route53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String(z),
ChangeBatch: &route53types.ChangeBatch{
Changes: b.Route53Changes(),
},
}
client := p.clients[zones[z].profile]
if _, err := client.ChangeResourceRecordSets(ctx, params); err != nil {
log.Errorf("Failure in zone %s when submitting change batch: %v", *zones[z].zone.Name, err)
successfulChanges := 0
changesByOwnership := groupChangesByNameAndOwnershipRelation(b)
client := p.clients[zones[z].profile]
if _, err := client.ChangeResourceRecordSets(ctx, params); err != nil {
log.Errorf("Failure in zone %s when submitting change batch: %v", *zones[z].zone.Name, err)
if len(changesByOwnership) > 1 {
log.Debug("Trying to submit change sets one-by-one instead")
for _, changes := range changesByOwnership {
if log.Logger.IsLevelEnabled(debugLevel) {
for _, c := range changes {
log.Debugf("Desired change: %s %s %s", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type)
}
}
params.ChangeBatch = &route53types.ChangeBatch{
Changes: changes.Route53Changes(),
}
if _, err := client.ChangeResourceRecordSets(ctx, params); err != nil {
failedUpdate = true
log.Errorf("Failed submitting change (error: %v), it will be retried in a separate change batch in the next iteration", err)
p.failedChangesQueue[z] = append(p.failedChangesQueue[z], changes...)
} else {
successfulChanges = successfulChanges + len(changes)
changesByOwnership := groupChangesByNameAndOwnershipRelation(b)
if len(changesByOwnership) > 1 {
log.Debug("Trying to submit change sets one-by-one instead")
for _, changes := range changesByOwnership {
if log.Logger.IsLevelEnabled(debugLevel) {
for _, c := range changes {
log.Debugf("Desired change: %s %s %s", c.Action, *c.ResourceRecordSet.Name, c.ResourceRecordSet.Type)
}
}
} else {
failedUpdate = true
params.ChangeBatch = &route53types.ChangeBatch{
Changes: changes.Route53Changes(),
}
if _, err := client.ChangeResourceRecordSets(ctx, params); err != nil {
failedUpdate = true
log.Errorf("Failed submitting change (error: %v), it will be retried in a separate change batch in the next iteration", err)
p.failedChangesQueue[z] = append(p.failedChangesQueue[z], changes...)
} else {
successfulChanges = successfulChanges + len(changes)
}
}
} else {
successfulChanges = len(b)
failedUpdate = true
}
} else {
successfulChanges = len(b)
}
if successfulChanges > 0 {
// z is the R53 Hosted Zone ID already as aws.StringValue
log.Infof("%d record(s) were successfully updated", successfulChanges)
}
if successfulChanges > 0 {
// z is the R53 Hosted Zone ID already as aws.StringValue
log.Infof("%d record(s) were successfully updated", successfulChanges)
}
if i != len(batchCs)-1 {
time.Sleep(p.batchChangeInterval)
}
if i != len(batchCs)-1 {
time.Sleep(p.batchChangeInterval)
}
}

View File

@ -32,6 +32,7 @@ import (
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"
@ -106,8 +107,8 @@ 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
@ -127,7 +128,23 @@ type zoneService struct {
}
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) {
@ -147,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) {
@ -167,6 +184,11 @@ 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
@ -202,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.
@ -343,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)
@ -352,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
}
@ -722,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

@ -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"
@ -52,15 +55,14 @@ type MockAction struct {
}
type mockCloudFlareClient struct {
Zones map[string]string
Records map[string]map[string]cloudflare.DNSRecord
Actions []MockAction
listZonesError error
zoneDetailsError error
listZonesContextError error
dnsRecordsError error
customHostnames map[string][]cloudflare.CustomHostname
regionalHostnames map[string][]regionalHostname
Zones map[string]string
Records map[string]map[string]cloudflare.DNSRecord
Actions []MockAction
listZonesError error // For v4 ListZones
getZoneError error // For v4 GetZone
dnsRecordsError error
customHostnames map[string][]cloudflare.CustomHostname
regionalHostnames map[string][]regionalHostname
}
var ExampleDomain = []cloudflare.DNSRecord{
@ -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")
@ -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

@ -32,14 +32,16 @@ func TestZoneIDName(t *testing.T) {
z.Add("654321", "foo.qux.baz")
z.Add("987654", "エイミー.みんな")
z.Add("123123", "_metadata.example.com")
z.Add("1231231", "_foo._metadata.example.com")
z.Add("456456", "_metadata.エイミー.みんな")
assert.Equal(t, ZoneIDName{
"123456": "qux.baz",
"654321": "foo.qux.baz",
"987654": "エイミー.みんな",
"123123": "_metadata.example.com",
"456456": "_metadata.エイミー.みんな",
"123456": "qux.baz",
"654321": "foo.qux.baz",
"987654": "エイミー.みんな",
"123123": "_metadata.example.com",
"1231231": "_foo._metadata.example.com",
"456456": "_metadata.エイミー.みんな",
}, z)
// simple entry in a domain
@ -77,6 +79,10 @@ func TestZoneIDName(t *testing.T) {
assert.Equal(t, "エイミー.みんな", zoneName)
assert.Equal(t, "987654", zoneID)
zoneID, zoneName = z.FindZone("_foo._metadata.example.com")
assert.Equal(t, "_foo._metadata.example.com", zoneName)
assert.Equal(t, "1231231", zoneID)
hook := testutils.LogsUnderTestWithLogLevel(log.WarnLevel, t)
_, _ = z.FindZone("???")

60
source/informers/fake.go Normal file
View File

@ -0,0 +1,60 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package informers
import (
"github.com/stretchr/testify/mock"
corev1lister "k8s.io/client-go/listers/core/v1"
discoveryv1lister "k8s.io/client-go/listers/discovery/v1"
"k8s.io/client-go/tools/cache"
)
type FakeServiceInformer struct {
mock.Mock
}
func (f *FakeServiceInformer) Informer() cache.SharedIndexInformer {
args := f.Called()
return args.Get(0).(cache.SharedIndexInformer)
}
func (f *FakeServiceInformer) Lister() corev1lister.ServiceLister {
return corev1lister.NewServiceLister(f.Informer().GetIndexer())
}
type FakeEndpointSliceInformer struct {
mock.Mock
}
func (f *FakeEndpointSliceInformer) Informer() cache.SharedIndexInformer {
args := f.Called()
return args.Get(0).(cache.SharedIndexInformer)
}
func (f *FakeEndpointSliceInformer) Lister() discoveryv1lister.EndpointSliceLister {
return discoveryv1lister.NewEndpointSliceLister(f.Informer().GetIndexer())
}
type FakeNodeInformer struct {
mock.Mock
}
func (f *FakeNodeInformer) Informer() cache.SharedIndexInformer {
args := f.Called()
return args.Get(0).(cache.SharedIndexInformer)
}
func (f *FakeNodeInformer) Lister() corev1lister.NodeLister {
return corev1lister.NewNodeLister(f.Informer().GetIndexer())
}

View File

@ -96,28 +96,9 @@ func NewServiceSource(ctx context.Context, kubeClient kubernetes.Interface, name
// Set the resync period to 0 to prevent processing when nothing has changed
informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace))
serviceInformer := informerFactory.Core().V1().Services()
endpointSlicesInformer := informerFactory.Discovery().V1().EndpointSlices()
podInformer := informerFactory.Core().V1().Pods()
// Add default resource event handlers to properly initialize informer.
_, _ = serviceInformer.Informer().AddEventHandler(
cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
},
},
)
_, _ = endpointSlicesInformer.Informer().AddEventHandler(
cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
},
},
)
_, _ = podInformer.Informer().AddEventHandler(
cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
},
},
)
_, _ = serviceInformer.Informer().AddEventHandler(informers.DefaultEventHandler())
// Transform the slice into a map so it will be way much easier and fast to filter later
sTypesFilter, err := newServiceTypesFilter(serviceTypeFilter)
@ -125,30 +106,40 @@ func NewServiceSource(ctx context.Context, kubeClient kubernetes.Interface, name
return nil, err
}
var nodeInformer coreinformers.NodeInformer
if sTypesFilter.isNodeInformerRequired() {
nodeInformer = informerFactory.Core().V1().Nodes()
_, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler())
var endpointSlicesInformer discoveryinformers.EndpointSliceInformer
var podInformer coreinformers.PodInformer
if sTypesFilter.isRequired(v1.ServiceTypeNodePort, v1.ServiceTypeClusterIP) {
endpointSlicesInformer = informerFactory.Discovery().V1().EndpointSlices()
podInformer = informerFactory.Core().V1().Pods()
_, _ = endpointSlicesInformer.Informer().AddEventHandler(informers.DefaultEventHandler())
_, _ = podInformer.Informer().AddEventHandler(informers.DefaultEventHandler())
// Add an indexer to the EndpointSlice informer to index by the service name label
err = endpointSlicesInformer.Informer().AddIndexers(cache.Indexers{
serviceNameIndexKey: func(obj any) ([]string, error) {
endpointSlice, ok := obj.(*discoveryv1.EndpointSlice)
if !ok {
// This should never happen because the Informer should only contain EndpointSlice objects
return nil, fmt.Errorf("expected %T but got %T instead", endpointSlice, obj)
}
serviceName := endpointSlice.Labels[discoveryv1.LabelServiceName]
if serviceName == "" {
return nil, nil
}
key := types.NamespacedName{Namespace: endpointSlice.Namespace, Name: serviceName}.String()
return []string{key}, nil
},
})
if err != nil {
return nil, err
}
}
// Add an indexer to the EndpointSlice informer to index by the service name label
err = endpointSlicesInformer.Informer().AddIndexers(cache.Indexers{
serviceNameIndexKey: func(obj any) ([]string, error) {
endpointSlice, ok := obj.(*discoveryv1.EndpointSlice)
if !ok {
// This should never happen because the Informer should only contain EndpointSlice objects
return nil, fmt.Errorf("expected %T but got %T instead", endpointSlice, obj)
}
serviceName := endpointSlice.Labels[discoveryv1.LabelServiceName]
if serviceName == "" {
return nil, nil
}
key := types.NamespacedName{Namespace: endpointSlice.Namespace, Name: serviceName}.String()
return []string{key}, nil
},
})
if err != nil {
return nil, err
var nodeInformer coreinformers.NodeInformer
if sTypesFilter.isRequired(v1.ServiceTypeNodePort) {
nodeInformer = informerFactory.Core().V1().Nodes()
_, _ = nodeInformer.Informer().AddEventHandler(informers.DefaultEventHandler())
}
informerFactory.Start(ctx.Done())
@ -808,10 +799,10 @@ func (sc *serviceSource) AddEventHandler(_ context.Context, handler func()) {
// Right now there is no way to remove event handler from informer, see:
// https://github.com/kubernetes/kubernetes/issues/79610
_, _ = sc.serviceInformer.Informer().AddEventHandler(eventHandlerFunc(handler))
if sc.listenEndpointEvents {
if sc.listenEndpointEvents && sc.serviceTypeFilter.isRequired(v1.ServiceTypeNodePort, v1.ServiceTypeClusterIP) {
_, _ = sc.endpointSlicesInformer.Informer().AddEventHandler(eventHandlerFunc(handler))
}
if sc.serviceTypeFilter.isNodeInformerRequired() {
if sc.serviceTypeFilter.isRequired(v1.ServiceTypeNodePort) {
_, _ = sc.nodeInformer.Informer().AddEventHandler(eventHandlerFunc(handler))
}
}
@ -848,12 +839,18 @@ func (sc *serviceTypes) isProcessed(serviceType v1.ServiceType) bool {
return !sc.enabled || sc.types[serviceType]
}
func (sc *serviceTypes) isNodeInformerRequired() bool {
if !sc.enabled {
// isRequired returns true if service type filtering is disabled or if any of the provided service types are present in the filter.
// If no options are provided, it returns true.
func (sc *serviceTypes) isRequired(opts ...v1.ServiceType) bool {
if len(opts) == 0 || !sc.enabled {
return true
}
_, ok := sc.types[v1.ServiceTypeNodePort]
return ok
for _, opt := range opts {
if _, ok := sc.types[opt]; ok {
return true
}
}
return false
}
// conditionToBool converts an EndpointConditions condition to a bool value.

View File

@ -37,6 +37,7 @@ import (
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes/fake"
"sigs.k8s.io/external-dns/source/informers"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/internal/testutils"
@ -251,7 +252,7 @@ func testServiceSourceEndpoints(t *testing.T) {
},
externalIPs: []string{},
lbs: []string{"1.2.3.4"},
serviceTypesFilter: []string{},
serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer)},
expected: []*endpoint.Endpoint{
{DNSName: "foo.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}},
},
@ -296,7 +297,7 @@ func testServiceSourceEndpoints(t *testing.T) {
annotations: map[string]string{},
externalIPs: []string{},
lbs: []string{"1.2.3.4"},
serviceTypesFilter: []string{},
serviceTypesFilter: []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)},
expected: []*endpoint.Endpoint{
{DNSName: "foo.fqdn.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}},
{DNSName: "foo.fqdn.com", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}},
@ -2498,6 +2499,7 @@ func TestHeadlessServices(t *testing.T) {
podsReady []bool
publishNotReadyAddresses bool
nodes []v1.Node
serviceTypesFilter []string
expected []*endpoint.Endpoint
expectError bool
}{
@ -2528,6 +2530,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}},
{DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}},
@ -2562,6 +2565,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true},
false,
[]v1.Node{},
[]string{string(v1.ServiceTypeClusterIP), string(v1.ServiceTypeLoadBalancer)},
[]*endpoint.Endpoint{
{DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}},
{DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::2"}},
@ -2596,6 +2600,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{},
false,
},
@ -2627,6 +2632,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: endpoint.TTL(1)},
{DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}, RecordTTL: endpoint.TTL(1)},
@ -2662,6 +2668,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1"}, RecordTTL: endpoint.TTL(1)},
{DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::2"}, RecordTTL: endpoint.TTL(1)},
@ -2696,6 +2703,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, false},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}},
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}},
@ -2729,6 +2737,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, false},
true,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "foo-0.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1"}},
{DNSName: "foo-1.service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.2"}},
@ -2763,6 +2772,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}},
},
@ -2795,6 +2805,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}},
},
@ -2827,6 +2838,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::1", "2001:db8::2"}},
},
@ -2861,6 +2873,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true, true},
false,
[]v1.Node{},
[]string{string(v1.ServiceTypeClusterIP)},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}},
},
@ -2895,6 +2908,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}},
},
@ -2939,6 +2953,7 @@ func TestHeadlessServices(t *testing.T) {
},
},
},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}},
},
@ -2987,6 +3002,7 @@ func TestHeadlessServices(t *testing.T) {
},
},
},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::5"}},
},
@ -3031,6 +3047,7 @@ func TestHeadlessServices(t *testing.T) {
},
},
},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}},
},
@ -3079,6 +3096,7 @@ func TestHeadlessServices(t *testing.T) {
},
},
},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}},
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}},
@ -3113,6 +3131,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeA, Targets: endpoint.Targets{"1.2.3.4"}},
},
@ -3146,6 +3165,7 @@ func TestHeadlessServices(t *testing.T) {
[]bool{true, true, true},
false,
[]v1.Node{},
[]string{},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", RecordType: endpoint.RecordTypeAAAA, Targets: endpoint.Targets{"2001:db8::4"}},
},
@ -3242,7 +3262,7 @@ func TestHeadlessServices(t *testing.T) {
true,
false,
false,
[]string{},
tc.serviceTypesFilter,
tc.ignoreHostnameAnnotation,
labels.Everything(),
false,
@ -3994,7 +4014,6 @@ func TestHeadlessServicesHostIP(t *testing.T) {
t.Run(tc.title, func(t *testing.T) {
t.Parallel()
// Create a Kubernetes testing client
kubernetes := fake.NewClientset()
service := &v1.Service{
@ -4134,7 +4153,7 @@ func TestExternalServices(t *testing.T) {
},
"111.111.111.111",
[]string{},
[]string{},
[]string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeExternalName)},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", Targets: endpoint.Targets{"111.111.111.111"}, RecordType: endpoint.RecordTypeA},
},
@ -4176,7 +4195,7 @@ func TestExternalServices(t *testing.T) {
},
"remote.example.com",
[]string{},
[]string{},
[]string{string(v1.ServiceTypeExternalName)},
[]*endpoint.Endpoint{
{DNSName: "service.example.org", Targets: endpoint.Targets{"remote.example.com"}, RecordType: endpoint.RecordTypeCNAME},
},
@ -4371,6 +4390,8 @@ func TestNewServiceSourceInformersEnabled(t *testing.T) {
assert.NotNil(t, svc.serviceTypeFilter)
assert.False(t, svc.serviceTypeFilter.enabled)
assert.NotNil(t, svc.nodeInformer)
assert.NotNil(t, svc.serviceInformer)
assert.NotNil(t, svc.endpointSlicesInformer)
},
},
{
@ -4380,17 +4401,49 @@ func TestNewServiceSourceInformersEnabled(t *testing.T) {
assert.NotNil(t, svc)
assert.NotNil(t, svc.serviceTypeFilter)
assert.True(t, svc.serviceTypeFilter.enabled)
assert.NotNil(t, svc.serviceInformer)
assert.Nil(t, svc.nodeInformer)
assert.NotNil(t, svc.endpointSlicesInformer)
assert.NotNil(t, svc.podInformer)
},
},
{
name: "serviceTypeFilter contains NodePort",
svcFilter: []string{string(v1.ServiceTypeNodePort)},
name: "serviceTypeFilter contains NodePort and ExternalName",
svcFilter: []string{string(v1.ServiceTypeNodePort), string(v1.ServiceTypeExternalName)},
asserts: func(svc *serviceSource) {
assert.NotNil(t, svc)
assert.NotNil(t, svc.serviceTypeFilter)
assert.True(t, svc.serviceTypeFilter.enabled)
assert.NotNil(t, svc.serviceInformer)
assert.NotNil(t, svc.nodeInformer)
assert.NotNil(t, svc.endpointSlicesInformer)
assert.NotNil(t, svc.podInformer)
},
},
{
name: "serviceTypeFilter contains ExternalName",
svcFilter: []string{string(v1.ServiceTypeExternalName)},
asserts: func(svc *serviceSource) {
assert.NotNil(t, svc)
assert.NotNil(t, svc.serviceTypeFilter)
assert.True(t, svc.serviceTypeFilter.enabled)
assert.NotNil(t, svc.serviceInformer)
assert.Nil(t, svc.nodeInformer)
assert.Nil(t, svc.endpointSlicesInformer)
assert.Nil(t, svc.podInformer)
},
},
{
name: "serviceTypeFilter contains LoadBalancer",
svcFilter: []string{string(v1.ServiceTypeLoadBalancer)},
asserts: func(svc *serviceSource) {
assert.NotNil(t, svc)
assert.NotNil(t, svc.serviceTypeFilter)
assert.True(t, svc.serviceTypeFilter.enabled)
assert.NotNil(t, svc.serviceInformer)
assert.Nil(t, svc.nodeInformer)
assert.Nil(t, svc.endpointSlicesInformer)
assert.Nil(t, svc.podInformer)
},
},
}
@ -4681,32 +4734,126 @@ func createTestServicesByType(namespace string, typeCounts map[v1.ServiceType]in
func TestServiceTypes_isNodeInformerRequired(t *testing.T) {
tests := []struct {
name string
filter []string
want bool
name string
filter []string
required []v1.ServiceType
want bool
}{
{
name: "NodePort type present",
filter: []string{string(v1.ServiceTypeNodePort)},
want: true,
name: "NodePort required and filter is empty",
filter: []string{},
required: []v1.ServiceType{v1.ServiceTypeNodePort},
want: true,
},
{
name: "NodePort type absent, filter enabled",
filter: []string{string(v1.ServiceTypeLoadBalancer)},
want: false,
name: "NodePort type present",
filter: []string{string(v1.ServiceTypeNodePort)},
required: []v1.ServiceType{v1.ServiceTypeNodePort},
want: true,
},
{
name: "NodePort and other filters present",
filter: []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)},
want: true,
name: "NodePort type absent, filter enabled",
filter: []string{string(v1.ServiceTypeLoadBalancer)},
required: []v1.ServiceType{v1.ServiceTypeNodePort},
want: false,
},
{
name: "NodePort and other filters present",
filter: []string{string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeNodePort)},
required: []v1.ServiceType{v1.ServiceTypeNodePort},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, _ := newServiceTypesFilter(tt.filter)
got := filter.isNodeInformerRequired()
got := filter.isRequired(tt.required...)
assert.Equal(t, tt.want, got)
})
}
}
func TestServiceSource_AddEventHandler(t *testing.T) {
var fakeServiceInformer *informers.FakeServiceInformer
var fakeEdpInformer *informers.FakeEndpointSliceInformer
var fakeNodeInformer *informers.FakeNodeInformer
tests := []struct {
name string
filter []string
times int
asserts func(t *testing.T, s *serviceSource)
}{
{
name: "AddEventHandler should trigger all event handlers when empty filter is provided",
filter: []string{},
times: 3,
asserts: func(t *testing.T, s *serviceSource) {
fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1)
fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 1)
fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 1)
},
},
{
name: "AddEventHandler should trigger only service event handler",
filter: []string{string(v1.ServiceTypeExternalName), string(v1.ServiceTypeLoadBalancer)},
times: 1,
asserts: func(t *testing.T, s *serviceSource) {
fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1)
fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 0)
fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 0)
},
},
{
name: "AddEventHandler should configure only service event handler",
filter: []string{string(v1.ServiceTypeExternalName), string(v1.ServiceTypeLoadBalancer), string(v1.ServiceTypeClusterIP)},
times: 2,
asserts: func(t *testing.T, s *serviceSource) {
fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1)
fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 1)
fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 0)
},
},
{
name: "AddEventHandler should configure all service event handlers",
filter: []string{string(v1.ServiceTypeNodePort)},
times: 3,
asserts: func(t *testing.T, s *serviceSource) {
fakeServiceInformer.AssertNumberOfCalls(t, "Informer", 1)
fakeEdpInformer.AssertNumberOfCalls(t, "Informer", 1)
fakeNodeInformer.AssertNumberOfCalls(t, "Informer", 1)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeServiceInformer = new(informers.FakeServiceInformer)
infSvc := testInformer{}
fakeServiceInformer.On("Informer").Return(&infSvc)
fakeEdpInformer = new(informers.FakeEndpointSliceInformer)
infEdp := testInformer{}
fakeEdpInformer.On("Informer").Return(&infEdp)
fakeNodeInformer = new(informers.FakeNodeInformer)
infNode := testInformer{}
fakeNodeInformer.On("Informer").Return(&infNode)
filter, _ := newServiceTypesFilter(tt.filter)
svcSource := &serviceSource{
endpointSlicesInformer: fakeEdpInformer,
serviceInformer: fakeServiceInformer,
nodeInformer: fakeNodeInformer,
serviceTypeFilter: filter,
listenEndpointEvents: true,
}
svcSource.AddEventHandler(t.Context(), func() {})
assert.Equal(t, tt.times, infSvc.times+infEdp.times+infNode.times)
tt.asserts(t, svcSource)
})
}
}

View File

@ -37,6 +37,8 @@ import (
"k8s.io/client-go/tools/clientcmd"
gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned"
"sigs.k8s.io/external-dns/source/types"
extdnshttp "sigs.k8s.io/external-dns/pkg/http"
"sigs.k8s.io/external-dns/pkg/apis/externaldns"
@ -332,53 +334,53 @@ func ByNames(ctx context.Context, p ClientGenerator, names []string, cfg *Config
// because they have simpler initialization requirements.
func BuildWithConfig(ctx context.Context, source string, p ClientGenerator, cfg *Config) (Source, error) {
switch source {
case "node":
case types.Node:
return buildNodeSource(ctx, p, cfg)
case "service":
case types.Service:
return buildServiceSource(ctx, p, cfg)
case "ingress":
case types.Ingress:
return buildIngressSource(ctx, p, cfg)
case "pod":
case types.Pod:
return buildPodSource(ctx, p, cfg)
case "gateway-httproute":
case types.GatewayHttpRoute:
return NewGatewayHTTPRouteSource(p, cfg)
case "gateway-grpcroute":
case types.GatewayGrpcRoute:
return NewGatewayGRPCRouteSource(p, cfg)
case "gateway-tlsroute":
case types.GatewayTlsRoute:
return NewGatewayTLSRouteSource(p, cfg)
case "gateway-tcproute":
case types.GatewayTcpRoute:
return NewGatewayTCPRouteSource(p, cfg)
case "gateway-udproute":
case types.GatewayUdpRoute:
return NewGatewayUDPRouteSource(p, cfg)
case "istio-gateway":
case types.IstioGateway:
return buildIstioGatewaySource(ctx, p, cfg)
case "istio-virtualservice":
case types.IstioVirtualService:
return buildIstioVirtualServiceSource(ctx, p, cfg)
case "cloudfoundry":
case types.Cloudfoundry:
return buildCloudFoundrySource(ctx, p, cfg)
case "ambassador-host":
case types.AmbassadorHost:
return buildAmbassadorHostSource(ctx, p, cfg)
case "contour-httpproxy":
case types.ContourHTTPProxy:
return buildContourHTTPProxySource(ctx, p, cfg)
case "gloo-proxy":
case types.GlooProxy:
return buildGlooProxySource(ctx, p, cfg)
case "traefik-proxy":
case types.TraefikProxy:
return buildTraefikProxySource(ctx, p, cfg)
case "openshift-route":
case types.OpenShiftRoute:
return buildOpenShiftRouteSource(ctx, p, cfg)
case "fake":
case types.Fake:
return NewFakeSource(cfg.FQDNTemplate)
case "connector":
case types.Connector:
return NewConnectorSource(cfg.ConnectorServer)
case "crd":
case types.CRD:
return buildCRDSource(ctx, p, cfg)
case "skipper-routegroup":
case types.SkipperRouteGroup:
return buildSkipperRouteGroupSource(ctx, cfg)
case "kong-tcpingress":
case types.KongTCPIngress:
return buildKongTCPIngressSource(ctx, p, cfg)
case "f5-virtualserver":
case types.F5VirtualServer:
return buildF5VirtualServerSource(ctx, p, cfg)
case "f5-transportserver":
case types.F5TransportServer:
return buildF5TransportServerSource(ctx, p, cfg)
}
return nil, ErrSourceNotFound

View File

@ -21,7 +21,7 @@ import (
"errors"
"testing"
cfclient "github.com/cloudfoundry-community/go-cfclient"
"github.com/cloudfoundry-community/go-cfclient"
openshift "github.com/openshift/client-go/route/clientset/versioned"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
@ -34,6 +34,7 @@ import (
fakeDynamic "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/kubernetes"
fakeKube "k8s.io/client-go/kubernetes/fake"
"sigs.k8s.io/external-dns/source/types"
gateway "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned"
)
@ -168,7 +169,10 @@ func (suite *ByNamesTestSuite) TestAllInitialized() {
}: "IngressRouteUDPList",
}), nil)
sources, err := ByNames(context.TODO(), mockClientGenerator, []string{"service", "ingress", "istio-gateway", "contour-httpproxy", "kong-tcpingress", "f5-virtualserver", "f5-transportserver", "traefik-proxy", "fake"}, &Config{})
sources, err := ByNames(context.TODO(), mockClientGenerator, []string{
types.Service, types.Ingress, types.IstioGateway, types.ContourHTTPProxy,
types.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer, types.TraefikProxy, types.Fake,
}, &Config{})
suite.NoError(err, "should not generate errors")
suite.Len(sources, 9, "should generate all nine sources")
}
@ -177,7 +181,7 @@ func (suite *ByNamesTestSuite) TestOnlyFake() {
mockClientGenerator := new(MockClientGenerator)
mockClientGenerator.On("KubeClient").Return(fakeKube.NewSimpleClientset(), nil)
sources, err := ByNames(context.TODO(), mockClientGenerator, []string{"fake"}, &Config{})
sources, err := ByNames(context.TODO(), mockClientGenerator, []string{types.Fake}, &Config{})
suite.NoError(err, "should not generate errors")
suite.Len(sources, 1, "should generate fake source")
suite.Nil(mockClientGenerator.kubeClient, "client should not be created")
@ -197,9 +201,9 @@ func (suite *ByNamesTestSuite) TestKubeClientFails() {
mockClientGenerator.On("KubeClient").Return(nil, errors.New("foo"))
sourcesDependentOnKubeClient := []string{
"node", "service", "ingress", "pod", "istio-gateway", "istio-virtualservice",
"ambassador-host", "gloo-proxy", "traefik-proxy", "crd", "kong-tcpingress",
"f5-virtualserver", "f5-transportserver",
types.Node, types.Service, types.Ingress, types.Pod, types.IstioGateway, types.IstioVirtualService,
types.AmbassadorHost, types.GlooProxy, types.TraefikProxy, types.CRD, types.KongTCPIngress,
types.F5VirtualServer, types.F5TransportServer,
}
for _, source := range sourcesDependentOnKubeClient {
@ -214,7 +218,7 @@ func (suite *ByNamesTestSuite) TestIstioClientFails() {
mockClientGenerator.On("IstioClient").Return(nil, errors.New("foo"))
mockClientGenerator.On("DynamicKubernetesClient").Return(nil, errors.New("foo"))
sourcesDependentOnIstioClient := []string{"istio-gateway", "istio-virtualservice"}
sourcesDependentOnIstioClient := []string{types.IstioGateway, types.IstioVirtualService}
for _, source := range sourcesDependentOnIstioClient {
_, err := ByNames(context.TODO(), mockClientGenerator, []string{source}, &Config{})
@ -228,8 +232,10 @@ func (suite *ByNamesTestSuite) TestDynamicKubernetesClientFails() {
mockClientGenerator.On("IstioClient").Return(istiofake.NewSimpleClientset(), nil)
mockClientGenerator.On("DynamicKubernetesClient").Return(nil, errors.New("foo"))
sourcesDependentOnDynamicKubernetesClient := []string{"ambassador-host", "contour-httpproxy", "gloo-proxy", "traefik-proxy",
"kong-tcpingress", "f5-virtualserver", "f5-transportserver"}
sourcesDependentOnDynamicKubernetesClient := []string{
types.AmbassadorHost, types.ContourHTTPProxy, types.GlooProxy, types.TraefikProxy,
types.KongTCPIngress, types.F5VirtualServer, types.F5TransportServer,
}
for _, source := range sourcesDependentOnDynamicKubernetesClient {
_, err := ByNames(context.TODO(), mockClientGenerator, []string{source}, &Config{})

46
source/types/types.go Normal file
View File

@ -0,0 +1,46 @@
/*
Copyright 2025 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package types
type Type = string
const (
Node Type = "node"
Service Type = "service"
Ingress Type = "ingress"
Pod Type = "pod"
GatewayHttpRoute Type = "gateway-httproute"
GatewayGrpcRoute Type = "gateway-grpcroute"
GatewayTlsRoute Type = "gateway-tlsroute"
GatewayTcpRoute Type = "gateway-tcproute"
GatewayUdpRoute Type = "gateway-udproute"
IstioGateway Type = "istio-gateway"
IstioVirtualService Type = "istio-virtualservice"
Cloudfoundry Type = "cloudfoundry"
AmbassadorHost Type = "ambassador-host"
ContourHTTPProxy Type = "contour-httpproxy"
GlooProxy Type = "gloo-proxy"
TraefikProxy Type = "traefik-proxy"
OpenShiftRoute Type = "openshift-route"
Fake Type = "fake"
Connector Type = "connector"
CRD Type = "crd"
SkipperRouteGroup Type = "skipper-routegroup"
KongTCPIngress Type = "kong-tcpingress"
F5VirtualServer Type = "f5-virtualserver"
F5TransportServer Type = "f5-transportserver"
)