Merge remote-tracking branch 'upstream/master' into provider

This commit is contained in:
Pascal Bachor 2025-07-29 14:47:28 +02:00
commit 48959eb41a
22 changed files with 1105 additions and 230 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.3
uses: renovatebot/github-action@v43.0.5
with:
# https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication
token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -52,7 +52,7 @@ jobs:
# Run Spectral
- name: Lint OpenAPI spec
uses: stoplightio/spectral-action@577bade2d6e0eeb50528c94182a5588bf961ae8f # v0.8.12
uses: stoplightio/spectral-action@6416fd018ae38e60136775066eb3e98172143141 # v0.8.13
with:
file_glob: 'api/*.yaml'

View File

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

@ -66,7 +66,7 @@
| `--google-zone-visibility=` | When using the Google provider, filter for zones with this visibility (optional, options: public, private) |
| `--alibaba-cloud-config-file="/etc/kubernetes/alibaba-cloud.json"` | When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud) |
| `--alibaba-cloud-zone-type=` | When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private) |
| `--aws-zone-type=` | When using the AWS provider, filter for zones of this type (optional, options: public, private) |
| `--aws-zone-type=` | When using the AWS provider, filter for zones of this type (optional, default: any, options: public, private) |
| `--aws-zone-tags=` | When using the AWS provider, filter for zones with these tags |
| `--aws-profile=` | When using the AWS provider, name of the profile to use |
| `--aws-assume-role=""` | When using the AWS API, assume this IAM role. Useful for hosted zones in another AWS account. Specify the full ARN, e.g. `arn:aws:iam::123455567:role/external-dns` (optional) |

View File

@ -20,6 +20,7 @@ Provider supported configurations
| Google GCP | n/a | yes | 300 |
| InMemory | n/a | n/a | n/a |
| Linode | n/a | n/a | n/a |
| Myra Security | n/a | yes | 300 |
| NS1 | n/a | yes | 10 |
| OCI | yes | yes | 300 |
| OVH | n/a | yes | 0 |

View File

@ -118,19 +118,19 @@ Note that the key used for encryption should be a secure key and properly manage
Python
```python
python -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())'
python -c 'import os,base64; print(base64.standard_b64encode(os.urandom(32)).decode())'
```
Bash
```shell
dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d -- '\n' | tr -- '+/' '-_'; echo
dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64; echo
```
OpenSSL
```shell
openssl rand -base64 32 | tr -- '+/' '-_'
openssl rand -base64 32
```
PowerShell
@ -138,7 +138,7 @@ PowerShell
```powershell
# Add System.Web assembly to session, just in case
Add-Type -AssemblyName System.Web
[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.Web.Security.Membership]::GeneratePassword(32,4))).Replace("+","-").Replace("/","_")
[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.Web.Security.Membership]::GeneratePassword(32,4)))
```
Terraform
@ -146,7 +146,6 @@ Terraform
```hcl
resource "random_password" "txt_key" {
length = 32
override_special = "-_"
}
```

View File

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

58
go.mod
View File

@ -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.5
github.com/aws/aws-sdk-go-v2/config v1.29.17
github.com/aws/aws-sdk-go-v2/credentials v1.17.70
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0
github.com/aws/aws-sdk-go-v2/service/route53 v1.53.0
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.7
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0
github.com/aws/aws-sdk-go-v2 v1.36.6
github.com/aws/aws-sdk-go-v2/config v1.29.18
github.com/aws/aws-sdk-go-v2/credentials v1.17.71
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.6
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.1
github.com/aws/aws-sdk-go-v2/service/route53 v1.53.1
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.8
github.com/aws/aws-sdk-go-v2/service/sts v1.34.1
github.com/bodgit/tsig v1.2.2
github.com/cenkalti/backoff/v5 v5.0.2
github.com/civo/civogo v0.6.1
github.com/cenkalti/backoff/v5 v5.0.3
github.com/civo/civogo v0.6.2
github.com/cloudflare/cloudflare-go v0.115.0
github.com/cloudflare/cloudflare-go/v4 v4.6.0
github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381
github.com/datawire/ambassador v1.12.4
github.com/denverdino/aliyungo v0.0.0-20230411124812-ab98a9173ace
github.com/digitalocean/godo v1.158.0
github.com/digitalocean/godo v1.160.0
github.com/dnsimple/dnsimple-go v1.7.0
github.com/exoscale/egoscale v0.102.3
github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99
@ -43,27 +43,27 @@ require (
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.95.2
github.com/oracle/oci-go-sdk/v65 v65.96.0
github.com/ovh/go-ovh v1.9.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pluralsh/gqlclient v1.12.2
github.com/projectcontour/contour v1.32.0
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/client_model v0.6.2
github.com/prometheus/common v0.64.0
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.2
go.etcd.io/etcd/client/v3 v3.6.2
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.242.0
google.golang.org/api v0.243.0
gopkg.in/ns1/ns1-go.v2 v2.14.4
istio.io/api v1.26.2
istio.io/client-go v1.26.2
@ -76,7 +76,7 @@ require (
)
require (
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth v0.16.3 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f // indirect
github.com/99designs/gqlgen v0.17.73 // indirect
@ -85,16 +85,16 @@ require (
github.com/Masterminds/semver v1.5.0 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0 // indirect
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.18 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 // indirect
github.com/aws/smithy-go v1.22.4 // indirect
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@ -121,7 +121,7 @@ require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@ -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.2 // 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
@ -184,8 +184,8 @@ require (
golang.org/x/sys v0.34.0 // indirect
golang.org/x/term v0.33.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect

120
go.sum
View File

@ -2,8 +2,8 @@ bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxo
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
@ -114,42 +114,42 @@ github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQ
github.com/aws/aws-sdk-go v1.15.11/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0=
github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0=
github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0=
github.com/aws/aws-sdk-go-v2/config v1.29.17 h1:jSuiQ5jEe4SAMH6lLRMY9OVC+TqJLP5655pBGjmnjr0=
github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8=
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 h1:ONnH5CM16RTXRkS8Z1qg7/s2eDOhHhaXVd72mmyv4/0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5 h1:oUEqVqonG3xuarrsze1KVJ30KagNYDemikTbdu8KlN8=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5/go.mod h1:VNM08cHlOsIbSHRqb6D/M2L4kKXfJv3A2/f0GNbOQSc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 h1:KAXP9JSHO1vKGCr5f4O6WmlVKLFFXgWYAGoJosorxzU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 h1:SsytQyTMHMDPspp+spo7XwXTP44aJZZAC7fBV2C5+5s=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 h1:i2vNHQiXUvKhs3quBR6aqlgJaiaexz/aNvdCktW/kAM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8=
github.com/aws/aws-sdk-go-v2 v1.36.6 h1:zJqGjVbRdTPojeCGWn5IR5pbJwSQSBh5RWFTQcEQGdU=
github.com/aws/aws-sdk-go-v2 v1.36.6/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0=
github.com/aws/aws-sdk-go-v2/config v1.29.18 h1:x4T1GRPnqKV8HMJOMtNktbpQMl3bIsfx8KbqmveUO2I=
github.com/aws/aws-sdk-go-v2/config v1.29.18/go.mod h1:bvz8oXugIsH8K7HLhBv06vDqnFv3NsGDt2Znpk7zmOU=
github.com/aws/aws-sdk-go-v2/credentials v1.17.71 h1:r2w4mQWnrTMJjOyIsZtGp3R3XGY3nqHn8C26C2lQWgA=
github.com/aws/aws-sdk-go-v2/credentials v1.17.71/go.mod h1:E7VF3acIup4GB5ckzbKFrCK0vTvEQxOxgdq4U3vcMCY=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.6 h1:gBfrCR6IwAhmx+oCf9i9FJo1+Cxx5f0In+PaYQbkqbU=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.6/go.mod h1:zAO6MqUum/2yfE/Ig1LPPtzCBudQtrGBaz1gcNzgAoY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 h1:D9ixiWSG4lyUBL2DDNK924Px9V/NBVpML90MHqyTADY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33/go.mod h1:caS/m4DI+cij2paz3rtProRBI4s/+TCiWoaWZuQ9010=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 h1:osMWfm/sC/L4tvEdQ65Gri5ZZDCUpuYJZbTTDrsn4I0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37/go.mod h1:ZV2/1fbjOPr4G4v38G3Ww5TBT4+hmsK45s/rxu1fGy0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 h1:v+X21AvTb2wZ+ycg1gx+orkB/9U6L7AOp93R7qYxsxM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37/go.mod h1:G0uM1kyssELxmJ2VZEfG0q2npObR3BAkF3c1VsfVnfs=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0 h1:A99gjqZDbdhjtjJVZrmVzVKO2+p3MSg35bDWtbMQVxw=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0 h1:0wOCTKrmwkyC8Bk76hYH/B4IJn5MGt6gMkSXc0A2uyc=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0/go.mod h1:He/RikglWUczbkV+fkdpcV/3GdL/rTRNVy7VaUiezMo=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.1 h1:UoEWyfuQ/yNOuDENk5nn+AgNCH2Y5yzQEv6YbTyhIV8=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.1/go.mod h1:K1I47BjiTRX00pBxfJLYK80QFRcf6blev2wbjgC5Cyc=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.1 h1:WD2RDt93+IgNvlxEKkx/b3BQrpw5G/YpDHvGXweO5wE=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.1/go.mod h1:8ZWruWnVWtJwjSHEtMWFcI1W6L6PD6i+uKCJ9EiJBbE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 h1:x187MqiHwBGjMGAed8Y8K1VGuCtFvQvXb24r+bwmSdo=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17/go.mod h1:mC9qMbA6e1pwEq6X3zDGtZRXMG2YaElJkbJlMVHLs5I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4=
github.com/aws/aws-sdk-go-v2/service/route53 v1.53.0 h1:UglIEyurCqfzZkjNdYAuXUGFu/FNWMKP5eorzggvXe8=
github.com/aws/aws-sdk-go-v2/service/route53 v1.53.0/go.mod h1:wi1naoiPnCQG3cyjsivwPON1ZmQt/EJGxFqXzubBTAw=
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.7 h1:1eaP4/444jrv04HhJdwTHtgnyxWgxwdLjSYBGq+oMB4=
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.7/go.mod h1:czoZQabc2chvmV/ak4oGSNR9CbcUw2bef3tatmwtoIA=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 h1:AIRJ3lfb2w/1/8wOOSqYb9fUKGwQbtysJ2H1MofRUPg=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 h1:BpOxT3yhLwSJ77qIY3DoHAQjZsc4HEGfMCE4NGy3uFg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.18 h1:QnGWwpTiazs1Y74RwA8VUfAtKuJQbnQ98DBFnSywj0s=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.18/go.mod h1:gWOI6Vb0Bbmsi0Ejvtt3RkwKpdoa/SOYTVUlzqYPRLc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 h1:vvbXsA2TVO80/KT7ZqCbx934dt6PY+vQ8hZpUZ/cpYg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18/go.mod h1:m2JJHledjBGNMsLOF1g9gbAxprzq3KjC8e4lxtn+eWg=
github.com/aws/aws-sdk-go-v2/service/route53 v1.53.1 h1:R3nSX1hguRy6MnknHiepSvqnnL8ansFwK2hidPesAYU=
github.com/aws/aws-sdk-go-v2/service/route53 v1.53.1/go.mod h1:fmSiB4OAghn85lQgk7XN9l9bpFg5Bm1v3HuaXKytPEw=
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.8 h1:PPQUm3zG6XzctspDTWC6vO3DvP/RZ+04RB11r98yb6E=
github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.35.8/go.mod h1:C1n2zhotURaNj/BNgdPdhXh/i6V53rI3RmVEaNDakSM=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 h1:rGtWqkQbPk7Bkwuv3NzpE/scwwL9sC1Ul3tn9x83DUI=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.6/go.mod h1:u4ku9OLv4TO4bCPdxf4fA1upaMaJmP9ZijGk3AAOC6Q=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 h1:OV/pxyXh+eMA0TExHEC4jyWdumLxNbzz1P0zJoezkJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4/go.mod h1:8Mm5VGYwtm+r305FfPSuc+aFkrypeylGYhFim6XEPoc=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 h1:aUrLQwJfZtwv3/ZNG2xRtEen+NqI3iesuacjP51Mv1s=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.1/go.mod h1:3wFBZKoWnX3r+Sm7in79i54fBmNfwhdNdQuscCw7QIk=
github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw=
github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
@ -172,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.2 h1:tQegf+coNxIKhLjOo5bwAV04CPSk6ealSod55XHb7cw=
github.com/civo/civogo v0.6.2/go.mod h1:LaEbkszc+9nXSh4YNG0sYXFGYqdQFmXXzQg0gESs2hc=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
@ -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.158.0 h1:XW7UlJn2X2qH8JOm6N63Hk/PjPHjLdYgG4zPaSbmbGc=
github.com/digitalocean/godo v1.158.0/go.mod h1:tYeiWY5ZXVpU48YaFv0M5irUFHXGorZpDNm7zzdWMzM=
github.com/digitalocean/godo v1.160.0 h1:3Wa6mOzv1m5DZQDANAk8u6v4DIUm5x2i4tZ7ke28lhs=
github.com/digitalocean/godo v1.160.0/go.mod h1:tYeiWY5ZXVpU48YaFv0M5irUFHXGorZpDNm7zzdWMzM=
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
github.com/dnsimple/dnsimple-go v1.7.0 h1:JKu9xJtZ3SqOC+BuYgAWeab7+EEx0sz422vu8j611ZY=
github.com/dnsimple/dnsimple-go v1.7.0/go.mod h1:EKpuihlWizqYafSnQHGCd/gyvy3HkEQJ7ODB4KdV8T8=
@ -503,8 +503,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU=
@ -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.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94=
github.com/oracle/oci-go-sdk/v65 v65.95.2/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA=
github.com/oracle/oci-go-sdk/v65 v65.96.0 h1:ew0WavsB6N/I6etYCC160cD5qDXbek/1xZgujqTzork=
github.com/oracle/oci-go-sdk/v65 v65.96.0/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA=
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
github.com/oxtoacart/bpool v0.0.0-20150712133111-4e1c5567d7c2 h1:CXwSGu/LYmbjEab5aMCs5usQRVBGThelUKBNnoSOuso=
@ -882,8 +882,8 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
@ -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.2 h1:25aCkIMjUmiiOtnBIp6PhNj4KdcURuBak0hU2P1fgRc=
go.etcd.io/etcd/api/v3 v3.6.2/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
go.etcd.io/etcd/client/pkg/v3 v3.6.2 h1:zw+HRghi/G8fKpgKdOcEKpnBTE4OO39T6MegA0RopVU=
go.etcd.io/etcd/client/pkg/v3 v3.6.2/go.mod h1:sbdzr2cl3HzVmxNw//PH7aLGVtY4QySjQFuaCgcRFAI=
go.etcd.io/etcd/client/v3 v3.6.2 h1:RgmcLJxkpHqpFvgKNwAQHX3K+wsSARMXKgjmUSpoSKQ=
go.etcd.io/etcd/client/v3 v3.6.2/go.mod h1:PL7e5QMKzjybn0FosgiWvCUDzvdChpo5UgGR4Sk4Gzc=
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.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/api v0.243.0 h1:sw+ESIJ4BVnlJcWu9S+p2Z6Qq1PjG77T8IJ1xtp4jZQ=
google.golang.org/api v0.243.0/go.mod h1:GE4QtYfaybx1KmeHMdBnNnyLzBZCVihGBXAmJu/uUr8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -1365,12 +1365,12 @@ google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRn
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=

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

@ -504,7 +504,7 @@ func App(cfg *Config) *kingpin.Application {
app.Flag("google-zone-visibility", "When using the Google provider, filter for zones with this visibility (optional, options: public, private)").Default(defaultConfig.GoogleZoneVisibility).EnumVar(&cfg.GoogleZoneVisibility, "", "public", "private")
app.Flag("alibaba-cloud-config-file", "When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud)").Default(defaultConfig.AlibabaCloudConfigFile).StringVar(&cfg.AlibabaCloudConfigFile)
app.Flag("alibaba-cloud-zone-type", "When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AlibabaCloudZoneType).EnumVar(&cfg.AlibabaCloudZoneType, "", "public", "private")
app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private")
app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, default: any, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private")
app.Flag("aws-zone-tags", "When using the AWS provider, filter for zones with these tags").Default("").StringsVar(&cfg.AWSZoneTagFilter)
app.Flag("aws-profile", "When using the AWS provider, name of the profile to use").Default("").StringsVar(&cfg.AWSProfiles)
app.Flag("aws-assume-role", "When using the AWS API, assume this IAM role. Useful for hosted zones in another AWS account. Specify the full ARN, e.g. `arn:aws:iam::123455567:role/external-dns` (optional)").Default(defaultConfig.AWSAssumeRole).StringVar(&cfg.AWSAssumeRole)

View File

@ -24,9 +24,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.
@ -419,16 +419,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

@ -729,56 +729,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

@ -78,7 +78,7 @@ type AWSSDProvider struct {
// only consider namespaces ending in this suffix
namespaceFilter *endpoint.DomainFilter
// filter namespace by type (private or public)
namespaceTypeFilter sdtypes.NamespaceFilter
namespaceTypeFilter []sdtypes.NamespaceFilter
// enables service without instances cleanup
cleanEmptyService bool
// filter services for removal
@ -102,21 +102,28 @@ func NewAWSSDProvider(domainFilter *endpoint.DomainFilter, namespaceType string,
return p, nil
}
// newSdNamespaceFilter initialized AWS SD Namespace Filter based on given string config
func newSdNamespaceFilter(namespaceTypeConfig string) sdtypes.NamespaceFilter {
// newSdNamespaceFilter returns NamespaceFilter based on the given namespace type configuration.
// If the config is "public", it filters for public namespaces; if "private", for private namespaces.
// For any other value (including empty), it returns filters for both public and private namespaces.
// ref: https://docs.aws.amazon.com/cloud-map/latest/api/API_ListNamespaces.html
func newSdNamespaceFilter(namespaceTypeConfig string) []sdtypes.NamespaceFilter {
switch namespaceTypeConfig {
case sdNamespaceTypePublic:
return sdtypes.NamespaceFilter{
Name: sdtypes.NamespaceFilterNameType,
Values: []string{string(sdtypes.NamespaceTypeDnsPublic)},
return []sdtypes.NamespaceFilter{
{
Name: sdtypes.NamespaceFilterNameType,
Values: []string{string(sdtypes.NamespaceTypeDnsPublic)},
},
}
case sdNamespaceTypePrivate:
return sdtypes.NamespaceFilter{
Name: sdtypes.NamespaceFilterNameType,
Values: []string{string(sdtypes.NamespaceTypeDnsPrivate)},
return []sdtypes.NamespaceFilter{
{
Name: sdtypes.NamespaceFilterNameType,
Values: []string{string(sdtypes.NamespaceTypeDnsPrivate)},
},
}
default:
return sdtypes.NamespaceFilter{}
return []sdtypes.NamespaceFilter{}
}
}
@ -354,7 +361,7 @@ func (p *AWSSDProvider) ListNamespaces(ctx context.Context) ([]*sdtypes.Namespac
namespaces := make([]*sdtypes.NamespaceSummary, 0)
paginator := sd.NewListNamespacesPaginator(p.client, &sd.ListNamespacesInput{
Filters: []sdtypes.NamespaceFilter{p.namespaceTypeFilter},
Filters: p.namespaceTypeFilter,
})
for paginator.HasMorePages() {
resp, err := paginator.NextPage(ctx)

View File

@ -254,7 +254,7 @@ func TestAWSSDProvider_ApplyChanges_Update(t *testing.T) {
ctx := context.Background()
// apply creates
provider.ApplyChanges(ctx, &plan.Changes{
_ = provider.ApplyChanges(ctx, &plan.Changes{
Create: oldEndpoints,
})
@ -263,7 +263,7 @@ func TestAWSSDProvider_ApplyChanges_Update(t *testing.T) {
// apply update
update, err := plan.MkUpdates(oldEndpoints, newEndpoints)
assert.NoError(t, err)
provider.ApplyChanges(ctx, &plan.Changes{
_ = provider.ApplyChanges(ctx, &plan.Changes{
Update: update,
})
@ -307,6 +307,7 @@ func TestAWSSDProvider_ListNamespaces(t *testing.T) {
}{
{"public filter", endpoint.NewDomainFilter([]string{}), "public", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces["public"])}},
{"private filter", endpoint.NewDomainFilter([]string{}), "private", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces["private"])}},
{"optional filter", endpoint.NewDomainFilter([]string{}), "", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces["public"]), namespaceToNamespaceSummary(namespaces["private"])}},
{"domain filter", endpoint.NewDomainFilter([]string{"public.com"}), "", []*sdtypes.NamespaceSummary{namespaceToNamespaceSummary(namespaces["public"])}},
{"non-existing domain", endpoint.NewDomainFilter([]string{"xxx.com"}), "", []*sdtypes.NamespaceSummary{}},
} {
@ -914,7 +915,7 @@ func TestAWSSDProvider_RegisterInstance(t *testing.T) {
}
// AWS NLB instance (ALIAS)
provider.RegisterInstance(context.Background(), services["private"]["alias-srv"], &endpoint.Endpoint{
_ = provider.RegisterInstance(context.Background(), services["private"]["alias-srv"], &endpoint.Endpoint{
RecordType: endpoint.RecordTypeCNAME,
DNSName: "service1.private.com.",
RecordTTL: 300,
@ -928,7 +929,7 @@ func TestAWSSDProvider_RegisterInstance(t *testing.T) {
}
// CNAME instance
provider.RegisterInstance(context.Background(), services["private"]["cname-srv"], &endpoint.Endpoint{
_ = provider.RegisterInstance(context.Background(), services["private"]["cname-srv"], &endpoint.Endpoint{
RecordType: endpoint.RecordTypeCNAME,
DNSName: "service2.private.com.",
RecordTTL: 300,
@ -1002,7 +1003,7 @@ func TestAWSSDProvider_DeregisterInstance(t *testing.T) {
provider := newTestAWSSDProvider(api, endpoint.NewDomainFilter([]string{}), "", "")
provider.DeregisterInstance(context.Background(), services["private"]["srv1"], endpoint.NewEndpoint("srv1.private.com.", endpoint.RecordTypeA, "1.2.3.4"))
_ = provider.DeregisterInstance(context.Background(), services["private"]["srv1"], endpoint.NewEndpoint("srv1.private.com.", endpoint.RecordTypeA, "1.2.3.4"))
assert.Empty(t, instances["srv1"])
}

View File

@ -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
}
@ -723,7 +749,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")
@ -2585,7 +2593,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"}),
@ -2593,6 +2601,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)
@ -2765,3 +2774,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("???")