diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index d9195b396..4ff5e91ae 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -7,8 +7,10 @@ assignees: '' --- - **What happened**: @@ -17,6 +19,10 @@ assignees: '' **How to reproduce it (as minimally and precisely as possible)**: + + **Anything else we need to know?**: **Environment**: diff --git a/.github/workflows/dependency-update.yaml b/.github/workflows/dependency-update.yaml index 6ab818a8c..e24f540f8 100644 --- a/.github/workflows/dependency-update.yaml +++ b/.github/workflows/dependency-update.yaml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v4.2.2 # https://github.com/renovatebot/github-action - name: self-hosted renovate - uses: renovatebot/github-action@v43.0.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 }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c04b5e890..c89e3db88 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -52,7 +52,7 @@ jobs: # Run Spectral - name: Lint OpenAPI spec - uses: stoplightio/spectral-action@577bade2d6e0eeb50528c94182a5588bf961ae8f # v0.8.12 + uses: stoplightio/spectral-action@6416fd018ae38e60136775066eb3e98172143141 # v0.8.13 with: file_glob: 'api/*.yaml' diff --git a/README.md b/README.md index 45ad8e06f..74c6c5cba 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ ExternalDNS allows you to keep selected zones (via `--domain-filter`) synchroniz - [Plural](https://www.plural.sh/) - [Pi-hole](https://pi-hole.net/) - [Alibaba Cloud DNS](https://www.alibabacloud.com/help/en/dns) +- [Myra Security DNS](https://www.myrasecurity.com/en/saasp/application-security/secure-dns/) ExternalDNS is, by default, aware of the records it is managing, therefore it can safely manage non-empty hosted zones. We strongly encourage you to set `--txt-owner-id` to a unique value that doesn't change for the lifetime of your cluster. @@ -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) diff --git a/docs/flags.md b/docs/flags.md index 68f6307c8..59f928882 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -66,7 +66,7 @@ | `--google-zone-visibility=` | When using the Google provider, filter for zones with this visibility (optional, options: public, private) | | `--alibaba-cloud-config-file="/etc/kubernetes/alibaba-cloud.json"` | When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud) | | `--alibaba-cloud-zone-type=` | When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private) | -| `--aws-zone-type=` | When using the AWS provider, filter for zones of this type (optional, options: public, private) | +| `--aws-zone-type=` | When using the AWS provider, filter for zones of this type (optional, default: any, options: public, private) | | `--aws-zone-tags=` | When using the AWS provider, filter for zones with these tags | | `--aws-profile=` | When using the AWS provider, name of the profile to use | | `--aws-assume-role=""` | When using the AWS API, assume this IAM role. Useful for hosted zones in another AWS account. Specify the full ARN, e.g. `arn:aws:iam::123455567:role/external-dns` (optional) | diff --git a/docs/providers.md b/docs/providers.md index 228622939..0aafa2fdd 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -20,6 +20,7 @@ Provider supported configurations | Google GCP | n/a | yes | 300 | | InMemory | n/a | n/a | n/a | | Linode | n/a | n/a | n/a | +| Myra Security | n/a | yes | 300 | | NS1 | n/a | yes | 10 | | OCI | yes | yes | 300 | | OVH | n/a | yes | 0 | diff --git a/docs/registry/txt.md b/docs/registry/txt.md index e3ae66d65..d538eb28e 100644 --- a/docs/registry/txt.md +++ b/docs/registry/txt.md @@ -118,19 +118,19 @@ Note that the key used for encryption should be a secure key and properly manage Python ```python -python -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())' +python -c 'import os,base64; print(base64.standard_b64encode(os.urandom(32)).decode())' ``` Bash ```shell -dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d -- '\n' | tr -- '+/' '-_'; echo +dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64; echo ``` OpenSSL ```shell -openssl rand -base64 32 | tr -- '+/' '-_' +openssl rand -base64 32 ``` PowerShell @@ -138,7 +138,7 @@ PowerShell ```powershell # Add System.Web assembly to session, just in case Add-Type -AssemblyName System.Web -[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.Web.Security.Membership]::GeneratePassword(32,4))).Replace("+","-").Replace("/","_") +[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.Web.Security.Membership]::GeneratePassword(32,4))) ``` Terraform @@ -146,7 +146,6 @@ Terraform ```hcl resource "random_password" "txt_key" { length = 32 - override_special = "-_" } ``` diff --git a/docs/tutorials/cloudflare.md b/docs/tutorials/cloudflare.md index 1147e0e26..4afb55e24 100644 --- a/docs/tutorials/cloudflare.md +++ b/docs/tutorials/cloudflare.md @@ -4,6 +4,37 @@ This tutorial describes how to setup ExternalDNS for usage within a Kubernetes c Make sure to use **>=0.4.2** version of ExternalDNS for this tutorial. +## CloudFlare SDK Migration Status + +ExternalDNS is currently migrating from the legacy CloudFlare Go SDK v0 to the modern v4 SDK to improve performance, reliability, and access to newer CloudFlare features. The migration status is: + +**✅ Fully migrated to v4 SDK:** + +- Zone management (listing, filtering, pagination) +- Zone details retrieval (`GetZone`) +- Zone ID lookup by name (`ZoneIDByName`) +- Zone plan detection (fully v4 implementation) +- Regional services (data localization) + +**🔄 Still using legacy v0 SDK:** + +- DNS record management (create, update, delete records) +- Custom hostnames +- Proxied records + +This mixed approach ensures continued functionality while gradually modernizing the codebase. Users should not experience any breaking changes during this transition. + +### SDK Dependencies + +ExternalDNS currently uses: + +- **cloudflare-go v0.115.0+**: Legacy SDK for DNS records, custom hostnames, and proxied record features +- **cloudflare-go/v4 v4.6.0+**: Modern SDK for all zone management and regional services operations + +Zone management has been fully migrated to the v4 SDK, providing improved performance and reliability. + +Both SDKs are automatically managed as Go module dependencies and require no special configuration from users. + ## Creating a Cloudflare DNS zone We highly recommend to read this tutorial if you haven't used Cloudflare before: @@ -353,7 +384,7 @@ The custom hostname DNS must resolve to the Cloudflare DNS record (`external-dns Requires [Cloudflare for SaaS](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/) product and "SSL and Certificates" API permission. -Due to a limitation within the cloudflare-go v0 API, the custom hostname page size is fixed at 50. +**Note:** Due to using the legacy cloudflare-go v0 API for custom hostname management, the custom hostname page size is fixed at 50. This limitation will be addressed in a future migration to the v4 SDK. ## Using CRD source to manage DNS records in Cloudflare diff --git a/docs/tutorials/myra.md b/docs/tutorials/myra.md new file mode 100644 index 000000000..c25e16821 --- /dev/null +++ b/docs/tutorials/myra.md @@ -0,0 +1,215 @@ +# Myra ExternalDNS Webhook + +This guide provides quick instructions for setting up and testing the [Myra ExternalDNS Webhook](https://github.com/Myra-Security-GmbH/external-dns-myrasec-webhook) in a Kubernetes environment. + +## Prerequisites + +- Kubernetes cluster (v1.19+) +- `kubectl` configured to access your cluster +- Docker for building the container image +- MyraSec API credentials (API key and secret) +- Domain registered with MyraSec + +## Quick Installation + +### 1. Build and Push the Docker Image + +```bash +# From the project root +docker build -t myra-webhook:latest . + +# Tag the image for your container registry +docker tag myra-webhook:latest YOUR_REGISTRY/myra-webhook:latest + +# Push to your container registry +docker push YOUR_REGISTRY/myra-webhook:latest +``` + +> **Important**: The image must be pushed to a container registry accessible by your Kubernetes cluster. Update the image reference in the deployment YAML file to match your registry path. + +### 2. Configure API Credentials + +Create a secret with your MyraSec API credentials: + +```bash +kubectl create secret generic myra-webhook-secrets \ + --from-literal=myrasec-api-key=YOUR_API_KEY \ + --from-literal=myrasec-api-secret=YOUR_API_SECRET \ + --from-literal=domain-filter=YOUR_DOMAIN.com +``` + +Alternatively, apply the provided secret template after editing: + +```bash +# Edit the secret file first +vi deploy/myra-webhook-secrets.yaml + +# Then apply +kubectl apply -f deploy/myra-webhook-secrets.yaml +``` + +### 3. Deploy the Webhook and ExternalDNS + +```bash +# Apply the combined deployment +kubectl apply -f deploy/combined-deployment.yaml +``` + +This deploys: + +- ConfigMap with webhook configuration +- ServiceAccount, ClusterRole, and ClusterRoleBinding for RBAC +- Deployment with two containers: + - myra-webhook: The webhook provider implementation + - external-dns: The ExternalDNS controller using the webhook provider + +### 4. Verify Deployment + +```bash +# Check if pods are running +kubectl get pods -l app=myra-externaldns + +# Check logs for the webhook container +kubectl logs -l app=myra-externaldns -c myra-webhook + +# Check logs for the external-dns container +kubectl logs -l app=myra-externaldns -c external-dns +``` + +## Manual Testing with NGINX Demo + +### 1. Deploy the NGINX Demo Application + +```bash +# Edit the domain in the nginx-demo.yaml file to match your domain +vi deploy/nginx-demo.yaml + +# Most important part is to set the correct domain in the external-dns.alpha.kubernetes.io/hostname annotation +# Example: +# annotations: +# external-dns.alpha.kubernetes.io/enabled: "true" +# external-dns.alpha.kubernetes.io/hostname: "nginx-demo.dummydomainforkubes.de" +# external-dns.alpha.kubernetes.io/target: "9.2.3.4" + +# Apply the demo resources +kubectl apply -f deploy/nginx-demo.yaml +``` + +This creates: + +- NGINX Deployment +- Service for the deployment +- Ingress resource with ExternalDNS annotations + +### 2. Verify DNS Record Creation + +After deploying the demo application, ExternalDNS should automatically create DNS records in MyraSec: + +```bash +# Check external-dns logs to see record creation +kubectl logs -l app=myra-externaldns -c external-dns | grep "nginx-demo" + +# Verify the webhook logs +kubectl logs -l app=myra-externaldns -c myra-webhook | grep "Created DNS record" +``` + +You can also verify through the MyraSec dashboard that the records were created. + +### 3. Testing Record Deletion + +To test record deletion: + +```bash +# Delete the nginx-demo resources or remove annotation from ingress +kubectl delete -f deploy/nginx-demo.yaml + +# Delete the ingress resource or remove annotation from ingress +# If resource is still active, external dns might still see the record and manage it +kubectl delete ingress nginx-demo -n default + +# Check external-dns logs to see record deletion +kubectl logs -l app=myra-externaldns -c external-dns | grep "nginx-demo" | grep "delete" + +# Verify the webhook logs +kubectl logs -l app=myra-externaldns -c myra-webhook | grep "Deleted DNS record" +``` + +## Configuration Options + +The webhook can be configured through the ConfigMap: + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `dry-run` | Run in dry-run mode without making actual changes | `"false"` | +| `environment` | Environment name (affects private IP handling) | `"prod"` | +| `log-level` | Logging level (debug, info, warn, error) | `"debug"` | +| `ttl` | Default TTL for DNS records | `"300"` | +| `webhook-listen-address` | Address and port for the webhook server | `":8080"` | + +## Troubleshooting + +### Common Issues + +1. **Webhook not receiving requests** + - Ensure the `webhook-provider-url` in the external-dns args is correct + - Check network connectivity between containers + +2. **DNS records not being created** + - Verify MyraSec API credentials are correct + - Check if the domain filter is properly configured + - Look for error messages in the webhook and external-dns logs + +3. **Permissions issues** + - Ensure the ServiceAccount has the correct RBAC permissions + +### Getting Help + +For more detailed logs: + +```bash +# Set log level to debug in the ConfigMap +kubectl edit configmap myra-externaldns-config +# Change log-level to "debug" + +# Restart the pods +kubectl rollout restart deployment myra-externaldns +``` + +## Environment Configuration + +The webhook supports different environment configurations through the `environment` setting in the ConfigMap: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: myra-externaldns-config +data: + environment: "prod" # Can be "prod", "staging", "dev", etc. +``` + +The environment setting affects how the webhook handles certain operations: + +| Environment | Behavior | +|-------------|----------| +| `prod`, `production`, `staging` | Strict mode: Skips private IP records, enforces stricter validation | +| `dev`, `development`, `test`, etc. | Development mode: Allows private IP records, more permissive validation | + +To modify the environment: + +```bash +# Edit the ConfigMap directly +kubectl edit configmap myra-externaldns-config + +# Or apply an updated YAML file +kubectl apply -f updated-config.yaml +``` + +## Advanced Configuration + +For production deployments, consider: + +1. Using a proper image registry instead of `latest` tag +2. Setting resource limits appropriate for your environment +3. Configuring horizontal pod autoscaling +4. Using Helm for deployment management diff --git a/endpoint/domain_filter.go b/endpoint/domain_filter.go index 47402bb76..8d8aad2dc 100644 --- a/endpoint/domain_filter.go +++ b/endpoint/domain_filter.go @@ -25,7 +25,8 @@ import ( "strings" log "github.com/sirupsen/logrus" - "golang.org/x/net/idna" + + "sigs.k8s.io/external-dns/internal/idna" ) type MatchAllDomainFilters []DomainFilterInterface @@ -247,9 +248,9 @@ func (df *DomainFilter) MatchParent(domain string) bool { } // normalizeDomain converts a domain to a canonical form, so that we can filter on it -// it: trim "." suffix, get Unicode version of domain complient with Section 5 of RFC 5891 +// it: trim "." suffix, get Unicode version of domain compliant with Section 5 of RFC 5891 func normalizeDomain(domain string) string { - s, err := idna.Lookup.ToUnicode(strings.TrimSuffix(domain, ".")) + s, err := idna.Profile.ToUnicode(strings.TrimSuffix(domain, ".")) if err != nil { log.Warnf(`Got error while parsing domain %s: %v`, domain, err) } diff --git a/go.mod b/go.mod index 498acdebf..50708ad3f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b7f9df376..5c773b789 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898/go.mod h1:Xbm+BRKSBEpa4q4hTSxo cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= -cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= +cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= @@ -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= diff --git a/internal/idna/idna.go b/internal/idna/idna.go new file mode 100644 index 000000000..9290e8a51 --- /dev/null +++ b/internal/idna/idna.go @@ -0,0 +1,29 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package idna + +import ( + "golang.org/x/net/idna" +) + +var ( + Profile = idna.New( + idna.MapForLookup(), + idna.Transitional(true), + idna.StrictDomainName(false), + ) +) diff --git a/internal/idna/idna_test.go b/internal/idna/idna_test.go new file mode 100644 index 000000000..f3ae93c44 --- /dev/null +++ b/internal/idna/idna_test.go @@ -0,0 +1,59 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package idna + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProfileWithDefault(t *testing.T) { + tets := []struct { + input string + expected string + }{ + { + input: "*.GÖPHER.com", + expected: "*.göpher.com", + }, + { + input: "*._abrakadabra.com", + expected: "*._abrakadabra.com", + }, + { + input: "_abrakadabra.com", + expected: "_abrakadabra.com", + }, + { + input: "*.foo.kube.example.com", + expected: "*.foo.kube.example.com", + }, + { + input: "xn--bcher-kva.example.com", + expected: "bücher.example.com", + }, + } + for _, tt := range tets { + t.Run(strings.ToLower(tt.input), func(t *testing.T) { + result, err := Profile.ToUnicode(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index ea809d3a5..c4df4e35f 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -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) diff --git a/plan/plan.go b/plan/plan.go index 617d2c431..1c2d4254f 100644 --- a/plan/plan.go +++ b/plan/plan.go @@ -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) } diff --git a/provider/aws/aws.go b/provider/aws/aws.go index b918bd798..c4a7c84c1 100644 --- a/provider/aws/aws.go +++ b/provider/aws/aws.go @@ -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) } } diff --git a/provider/awssd/aws_sd.go b/provider/awssd/aws_sd.go index 9be294536..25d625364 100644 --- a/provider/awssd/aws_sd.go +++ b/provider/awssd/aws_sd.go @@ -78,7 +78,7 @@ type AWSSDProvider struct { // only consider namespaces ending in this suffix namespaceFilter *endpoint.DomainFilter // filter namespace by type (private or public) - namespaceTypeFilter sdtypes.NamespaceFilter + namespaceTypeFilter []sdtypes.NamespaceFilter // enables service without instances cleanup cleanEmptyService bool // filter services for removal @@ -102,21 +102,28 @@ func NewAWSSDProvider(domainFilter *endpoint.DomainFilter, namespaceType string, return p, nil } -// newSdNamespaceFilter initialized AWS SD Namespace Filter based on given string config -func newSdNamespaceFilter(namespaceTypeConfig string) sdtypes.NamespaceFilter { +// newSdNamespaceFilter returns NamespaceFilter based on the given namespace type configuration. +// If the config is "public", it filters for public namespaces; if "private", for private namespaces. +// For any other value (including empty), it returns filters for both public and private namespaces. +// ref: https://docs.aws.amazon.com/cloud-map/latest/api/API_ListNamespaces.html +func newSdNamespaceFilter(namespaceTypeConfig string) []sdtypes.NamespaceFilter { switch namespaceTypeConfig { case sdNamespaceTypePublic: - return sdtypes.NamespaceFilter{ - Name: sdtypes.NamespaceFilterNameType, - Values: []string{string(sdtypes.NamespaceTypeDnsPublic)}, + return []sdtypes.NamespaceFilter{ + { + Name: sdtypes.NamespaceFilterNameType, + Values: []string{string(sdtypes.NamespaceTypeDnsPublic)}, + }, } case sdNamespaceTypePrivate: - return sdtypes.NamespaceFilter{ - Name: sdtypes.NamespaceFilterNameType, - Values: []string{string(sdtypes.NamespaceTypeDnsPrivate)}, + return []sdtypes.NamespaceFilter{ + { + Name: sdtypes.NamespaceFilterNameType, + Values: []string{string(sdtypes.NamespaceTypeDnsPrivate)}, + }, } default: - return sdtypes.NamespaceFilter{} + return []sdtypes.NamespaceFilter{} } } @@ -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) diff --git a/provider/awssd/aws_sd_test.go b/provider/awssd/aws_sd_test.go index 5d55ad59f..5212cbc08 100644 --- a/provider/awssd/aws_sd_test.go +++ b/provider/awssd/aws_sd_test.go @@ -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"]) } diff --git a/provider/cloudflare/cloudflare.go b/provider/cloudflare/cloudflare.go index 939e21836..d1a1b837f 100644 --- a/provider/cloudflare/cloudflare.go +++ b/provider/cloudflare/cloudflare.go @@ -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{} diff --git a/provider/cloudflare/cloudflare_test.go b/provider/cloudflare/cloudflare_test.go index 879204e26..3044fb514 100644 --- a/provider/cloudflare/cloudflare_test.go +++ b/provider/cloudflare/cloudflare_test.go @@ -27,9 +27,12 @@ import ( "testing" "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/cloudflare-go/v4/zones" "github.com/maxatome/go-testdeep/td" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" @@ -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") +} diff --git a/provider/zonefinder_test.go b/provider/zonefinder_test.go index dccfe5adb..4eddd36cb 100644 --- a/provider/zonefinder_test.go +++ b/provider/zonefinder_test.go @@ -32,14 +32,16 @@ func TestZoneIDName(t *testing.T) { z.Add("654321", "foo.qux.baz") z.Add("987654", "エイミー.みんな") z.Add("123123", "_metadata.example.com") + z.Add("1231231", "_foo._metadata.example.com") z.Add("456456", "_metadata.エイミー.みんな") assert.Equal(t, ZoneIDName{ - "123456": "qux.baz", - "654321": "foo.qux.baz", - "987654": "エイミー.みんな", - "123123": "_metadata.example.com", - "456456": "_metadata.エイミー.みんな", + "123456": "qux.baz", + "654321": "foo.qux.baz", + "987654": "エイミー.みんな", + "123123": "_metadata.example.com", + "1231231": "_foo._metadata.example.com", + "456456": "_metadata.エイミー.みんな", }, z) // simple entry in a domain @@ -77,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("???")